@redue/cli 0.1.0-alpha.26 → 0.1.0-alpha.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/landlock.py CHANGED
@@ -6,6 +6,32 @@
6
6
  # connection to an abstract Unix socket outside the run. Writes are not
7
7
  # handled, so they are not restricted.
8
8
  #
9
+ # --loopback (a test run) leaves TCP to a seccomp filter in the command's
10
+ # process instead. Sockets other than Unix, netlink and inet TCP streams are
11
+ # refused when they are created, and so are sendto, sendmsg and sendmmsg with
12
+ # MSG_FASTOPEN (a connect without connect), io_uring_setup, and any foreign
13
+ # architecture's or x32 system call. Every connect goes to the launcher
14
+ # (seccomp user notification). It lets through AF_UNSPEC, an unnamed or
15
+ # abstract Unix socket, a Unix socket file beneath a --write place, and the
16
+ # local host (loopback, 0.0.0.0, ::) on a port no process listens on or one
17
+ # of the run's own processes listens on: not a service started outside the
18
+ # run. It refuses the rest. Nothing else goes through the launcher, so worker
19
+ # processes' IPC is untouched; bind is allowed anywhere.
20
+ #
21
+ # --deny-write PATH (a test run's /tmp and /var/tmp) handles writes too: they
22
+ # are allowed everywhere but beneath those paths, and beneath each --write
23
+ # PATH (the run's own temporary directory).
24
+ #
25
+ # --hide PATH keeps the run from reading or executing beneath PATH, inside a
26
+ # --read place. Landlock only grants, beneath a whole directory, so such a
27
+ # place gets listing rights (beneath it all, hidden entries included) and
28
+ # read and execute rights on each of its entries that is not hidden and
29
+ # holds nothing hidden, descending into the ones that do. A hidden path can
30
+ # still be listed and stat'ed. --stats FILE writes the rule count and the
31
+ # time the rules took, as JSON; with --loopback, rewritten when the command
32
+ # ends with `supervisor`: "ok" when it served the whole run, else what went
33
+ # wrong. A missing or other value means the network was not supervised.
34
+ #
9
35
  # The launcher stays as the command's parent and a child subreaper: when the
10
36
  # command exits, it stops every process the command left running, daemons
11
37
  # that started their own session included (they are reparented to it), as a
@@ -13,23 +39,240 @@
13
39
  # launcher gets is passed to that group once. The launcher then ends as the
14
40
  # command did: with its exit status, or by its signal.
15
41
  #
16
- # landlock.py --read /usr --read /tmp ... [--no-tcp] -- cmd args...
42
+ # landlock.py --read /usr --read /tmp ... [--hide P ...] [--stats F] [--no-tcp | --loopback]
43
+ # [--deny-write P ... --write P ...] -- cmd args...
17
44
  # landlock.py --probe # prints {"abi": N} or {"abi": null, "errno": E}
18
45
  import ctypes
46
+ import errno
19
47
  import json
20
48
  import os
49
+ import select
21
50
  import signal
51
+ import socket
22
52
  import sys
53
+ import threading
23
54
  import time
24
55
 
25
56
  SYS_CREATE, SYS_ADD, SYS_RESTRICT = 444, 445, 446 # the same on x86_64 and aarch64
26
57
  EXECUTE, READ_FILE, READ_DIR = 1 << 0, 1 << 2, 1 << 3
58
+ # Writing, making and removing files, renaming across directories (ABI 2), truncating (ABI 3).
59
+ WRITE = sum(1 << bit for bit in (1, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14))
27
60
  BIND_TCP, CONNECT_TCP = 1 << 0, 1 << 1
28
61
  SCOPE_ABSTRACT_UNIX_SOCKET = 1 << 0
29
62
  PR_SET_NO_NEW_PRIVS, PR_SET_CHILD_SUBREAPER = 38, 36
30
63
 
31
64
  libc = ctypes.CDLL(None, use_errno=True)
32
65
  libc.syscall.restype = ctypes.c_long
66
+ libc.ioctl.argtypes = [ctypes.c_int, ctypes.c_ulong, ctypes.c_void_p]
67
+
68
+ # The seccomp filter --loopback installs, per architecture: its audit value
69
+ # and the system calls it looks at.
70
+ ARCH = {'x86_64': (0xC000003E, {'socket': 41, 'connect': 42, 'sendto': 44, 'sendmsg': 46, 'sendmmsg': 307,
71
+ 'io_uring_setup': 425, 'seccomp': 317}),
72
+ 'aarch64': (0xC00000B7, {'socket': 198, 'connect': 203, 'sendto': 206, 'sendmsg': 211, 'sendmmsg': 269,
73
+ 'io_uring_setup': 425, 'seccomp': 277})}
74
+ AF_UNSPEC, AF_UNIX, AF_INET, AF_INET6, AF_NETLINK = 0, 1, 2, 10, 16
75
+ SOCK_STREAM, IPPROTO_TCP, IPPROTO_MPTCP, MSG_FASTOPEN = 1, 6, 262, 0x20000000
76
+ RET_ALLOW, RET_NOTIFY, RET_ERRNO = 0x7fff0000, 0x7fc00000, 0x00050000
77
+ LD, JEQ, JGE, JSET, AND, RET = 0x20, 0x15, 0x35, 0x45, 0x54, 0x06
78
+ SECCOMP_SET_MODE_FILTER, SECCOMP_FILTER_FLAG_NEW_LISTENER, SECCOMP_USER_NOTIF_FLAG_CONTINUE = 1, 1 << 3, 1
79
+ NOTIF_RECV, NOTIF_SEND, NOTIF_ID_VALID, NOTIF_ID_VALID_OLD = 0xc0502100, 0xc0182101, 0x40082102, 0x80082102
80
+
81
+
82
+ class SockFilter(ctypes.Structure):
83
+ _fields_ = [('code', ctypes.c_uint16), ('jt', ctypes.c_uint8), ('jf', ctypes.c_uint8), ('k', ctypes.c_uint32)]
84
+
85
+
86
+ class SockFprog(ctypes.Structure):
87
+ _fields_ = [('len', ctypes.c_ushort), ('filter', ctypes.POINTER(SockFilter))]
88
+
89
+
90
+ class SeccompData(ctypes.Structure):
91
+ _fields_ = [('nr', ctypes.c_int), ('arch', ctypes.c_uint32), ('ip', ctypes.c_uint64), ('args', ctypes.c_uint64 * 6)]
92
+
93
+
94
+ class Notif(ctypes.Structure):
95
+ _fields_ = [('id', ctypes.c_uint64), ('pid', ctypes.c_uint32), ('flags', ctypes.c_uint32), ('data', SeccompData)]
96
+
97
+
98
+ class NotifResp(ctypes.Structure):
99
+ _fields_ = [('id', ctypes.c_uint64), ('val', ctypes.c_int64), ('error', ctypes.c_int32), ('flags', ctypes.c_uint32)]
100
+
101
+
102
+ def filter_program(machine):
103
+ # Little-endian: an argument's low 32 bits are at 16 + 8 * index.
104
+ audit, nr = ARCH[machine]
105
+ program, labels = [], {}
106
+ def op(code, k, jt=None, jf=None):
107
+ program.append([code, jt, jf, k])
108
+ def label(name):
109
+ labels[name] = len(program)
110
+ op(LD, 4); op(JEQ, audit, None, 'deny')
111
+ op(LD, 0)
112
+ if machine == 'x86_64':
113
+ op(JGE, 0x40000000, 'deny')
114
+ op(JEQ, nr['connect'], 'notify'); op(JEQ, nr['io_uring_setup'], 'deny'); op(JEQ, nr['socket'], 'socket')
115
+ op(JEQ, nr['sendto'], 'flags3'); op(JEQ, nr['sendmmsg'], 'flags3'); op(JEQ, nr['sendmsg'], 'flags2')
116
+ op(RET, RET_ALLOW)
117
+ # Unix and netlink sockets; inet only as TCP (or MPTCP) streams, which reach a host through connect alone.
118
+ label('socket'); op(LD, 16)
119
+ op(JEQ, AF_UNIX, 'allow'); op(JEQ, AF_NETLINK, 'allow'); op(JEQ, AF_INET, 'inet'); op(JEQ, AF_INET6, 'inet'); op(RET, RET_ERRNO | errno.EPERM)
120
+ label('inet'); op(LD, 24); op(AND, 0xf); op(JEQ, SOCK_STREAM, 'protocol', 'deny')
121
+ label('protocol'); op(LD, 32); op(JEQ, 0, 'allow'); op(JEQ, IPPROTO_TCP, 'allow'); op(JEQ, IPPROTO_MPTCP, 'allow', 'deny')
122
+ label('flags3'); op(LD, 16 + 8 * 3); op(JSET, MSG_FASTOPEN, 'deny', 'allow')
123
+ label('flags2'); op(LD, 16 + 8 * 2); op(JSET, MSG_FASTOPEN, 'deny', 'allow')
124
+ label('allow'); op(RET, RET_ALLOW)
125
+ label('notify'); op(RET, RET_NOTIFY)
126
+ label('deny'); op(RET, RET_ERRNO | errno.EPERM)
127
+ jump = lambda at, name: labels[name] - at - 1 if name else 0
128
+ return [SockFilter(code, jump(at, jt), jump(at, jf), k) for at, (code, jt, jf, k) in enumerate(program)]
129
+
130
+
131
+ # In the command's process, before exec: the filter, and its listener for the launcher.
132
+ def install_filter():
133
+ machine = os.uname().machine
134
+ if machine not in ARCH:
135
+ raise OSError(f'no seccomp filter for {machine}')
136
+ program = filter_program(machine)
137
+ buffer = (SockFilter * len(program))(*program)
138
+ fprog = SockFprog(len(program), ctypes.cast(buffer, ctypes.POINTER(SockFilter)))
139
+ fd = libc.syscall(ARCH[machine][1]['seccomp'], SECCOMP_SET_MODE_FILTER, SECCOMP_FILTER_FLAG_NEW_LISTENER, ctypes.byref(fprog))
140
+ if fd < 0:
141
+ raise OSError(ctypes.get_errno(), f'seccomp: {os.strerror(ctypes.get_errno())}')
142
+ return fd
143
+
144
+
145
+ # The run's processes: the launcher's descendants (it is their subreaper).
146
+ def run_processes(launcher):
147
+ parents = {}
148
+ for entry in os.listdir('/proc'):
149
+ if entry.isdigit():
150
+ try:
151
+ with open(f'/proc/{entry}/stat') as f:
152
+ stat = f.read()
153
+ parents[int(entry)] = int(stat[stat.rindex(')') + 2:].split()[1])
154
+ except (OSError, ValueError, IndexError):
155
+ pass
156
+ def descends(pid, seen=0):
157
+ while pid in parents and seen < 64:
158
+ pid, seen = parents[pid], seen + 1
159
+ if pid == launcher:
160
+ return True
161
+ return False
162
+ return [pid for pid in parents if descends(pid)]
163
+
164
+
165
+ # Who listens on a local TCP port: None when nobody does, else whether one of
166
+ # the run's processes does.
167
+ def listener_in_run(port, launcher):
168
+ inodes = set()
169
+ for table in ('/proc/net/tcp', '/proc/net/tcp6'):
170
+ try:
171
+ with open(table) as f:
172
+ rows = f.read().splitlines()[1:]
173
+ except OSError:
174
+ continue
175
+ for row in rows:
176
+ fields = row.split()
177
+ if len(fields) > 9 and fields[3] == '0A' and int(fields[1].rsplit(':', 1)[1], 16) == port:
178
+ inodes.add(fields[9])
179
+ if not inodes:
180
+ return None
181
+ links = {f'socket:[{inode}]' for inode in inodes}
182
+ for pid in run_processes(launcher):
183
+ try:
184
+ for fd in os.listdir(f'/proc/{pid}/fd'):
185
+ try:
186
+ if os.readlink(f'/proc/{pid}/fd/{fd}') in links:
187
+ return True
188
+ except OSError:
189
+ pass
190
+ except OSError:
191
+ pass
192
+ return False
193
+
194
+
195
+ # Whether a process may connect to the address it names (see --loopback).
196
+ def connect_allowed(pid, address, length, places, launcher, ports):
197
+ if not address or length < 2:
198
+ return False
199
+ try:
200
+ fd = os.open(f'/proc/{pid}/mem', os.O_RDONLY | os.O_CLOEXEC)
201
+ try:
202
+ raw = os.pread(fd, min(length, 128), address)
203
+ finally:
204
+ os.close(fd)
205
+ except OSError:
206
+ return False
207
+ family = int.from_bytes(raw[:2], sys.byteorder)
208
+ if family == AF_UNSPEC:
209
+ return True
210
+ if family == AF_UNIX:
211
+ name = raw[2:].split(b'\0', 1)[0] if len(raw) > 2 and raw[2] != 0 else b''
212
+ if not name:
213
+ return True
214
+ try:
215
+ target = os.path.realpath(os.path.join(os.readlink(f'/proc/{pid}/cwd'), os.fsdecode(name)))
216
+ except OSError:
217
+ return False
218
+ return any(target == place or target.startswith(place.rstrip('/') + '/') for place in places)
219
+ if family == AF_INET and len(raw) >= 8:
220
+ local = raw[4] == 127 or raw[4:8] == bytes(4)
221
+ elif family == AF_INET6 and len(raw) >= 24:
222
+ host = raw[8:24]
223
+ mapped = host[:12] == bytes(10) + b'\xff\xff'
224
+ local = host in (bytes(15) + b'\x01', bytes(16)) or mapped and (host[12] == 127 or host[12:] == bytes(4))
225
+ else:
226
+ return False
227
+ if not local:
228
+ return False
229
+ port = int.from_bytes(raw[2:4], 'big')
230
+ if port in ports:
231
+ return True
232
+ owner = listener_in_run(port, launcher)
233
+ if owner:
234
+ ports.add(port)
235
+ return owner is not False
236
+
237
+
238
+ # The launcher's side: every connect the command's processes make, until none is left.
239
+ def supervise(fd, state, places, die=False):
240
+ def valid(id):
241
+ value = ctypes.c_uint64(id)
242
+ if libc.ioctl(fd, NOTIF_ID_VALID, ctypes.byref(value)) == 0:
243
+ return True
244
+ return ctypes.get_errno() in (errno.EINVAL, errno.ENOTTY) and libc.ioctl(fd, NOTIF_ID_VALID_OLD, ctypes.byref(value)) == 0
245
+ launcher, ports = os.getpid(), set()
246
+ places = [os.path.realpath(place) for place in places]
247
+ try:
248
+ poller = select.poll()
249
+ poller.register(fd, select.POLLIN)
250
+ while True:
251
+ events = dict(poller.poll())
252
+ if events.get(fd, 0) & select.POLLIN == 0:
253
+ break
254
+ notif = Notif()
255
+ if libc.ioctl(fd, NOTIF_RECV, ctypes.byref(notif)) < 0:
256
+ if ctypes.get_errno() in (errno.EINTR, errno.ENOENT):
257
+ continue
258
+ raise OSError(ctypes.get_errno(), 'receive')
259
+ if die:
260
+ raise RuntimeError('test fault')
261
+ allowed = connect_allowed(notif.pid, notif.data.args[1], notif.data.args[2], places, launcher, ports)
262
+ state['connects'] = state.get('connects', 0) + 1
263
+ if not valid(notif.id):
264
+ continue
265
+ response = NotifResp(notif.id, 0, 0 if allowed else -errno.EPERM, SECCOMP_USER_NOTIF_FLAG_CONTINUE if allowed else 0)
266
+ if libc.ioctl(fd, NOTIF_SEND, ctypes.byref(response)) < 0 and ctypes.get_errno() != errno.ENOENT:
267
+ raise OSError(ctypes.get_errno(), 'respond')
268
+ state['supervisor'] = 'ok'
269
+ except Exception as e:
270
+ state['supervisor'] = f'failed: {e}'
271
+ # A closed listener makes every later connect fail (ENOSYS).
272
+ try:
273
+ os.close(fd)
274
+ except OSError:
275
+ pass
33
276
 
34
277
 
35
278
  class RulesetAttr(ctypes.Structure):
@@ -53,10 +296,23 @@ def main(argv):
53
296
  v, err = abi()
54
297
  print(json.dumps({'abi': v, 'errno': err}))
55
298
  return 0
56
- reads, no_tcp = [], False
299
+ reads, hidden, stats, no_tcp, loopback, deny_write, writes, fault = [], set(), None, False, False, set(), [], None
57
300
  while argv and argv[0] != '--':
58
301
  if argv[0] == '--read':
59
302
  reads.append(argv[1]); argv = argv[2:]
303
+ elif argv[0] == '--loopback':
304
+ loopback = True; argv = argv[1:]
305
+ elif argv[0] == '--deny-write':
306
+ deny_write.add(os.path.normpath(argv[1])); argv = argv[2:]
307
+ elif argv[0] == '--write':
308
+ writes.append(argv[1]); argv = argv[2:]
309
+ elif argv[0] == '--test-fault':
310
+ # Tests only: the supervisor cannot start (unavailable) or stops at the first connect (die).
311
+ fault = argv[1]; argv = argv[2:]
312
+ elif argv[0] == '--hide':
313
+ hidden.add(os.path.normpath(argv[1])); argv = argv[2:]
314
+ elif argv[0] == '--stats':
315
+ stats = argv[1]; argv = argv[2:]
60
316
  elif argv[0] == '--no-tcp':
61
317
  no_tcp = True; argv = argv[1:]
62
318
  else:
@@ -65,33 +321,101 @@ def main(argv):
65
321
  version, err = abi()
66
322
  if version is None:
67
323
  sys.exit(f'landlock.py: Landlock unavailable (errno {err})')
68
- handled = EXECUTE | READ_FILE | READ_DIR
324
+ reading = EXECUTE | READ_FILE | READ_DIR
325
+ handled = reading | (WRITE if deny_write else 0)
69
326
  attr = RulesetAttr(handled, (BIND_TCP | CONNECT_TCP) if no_tcp and version >= 4 else 0,
70
- SCOPE_ABSTRACT_UNIX_SOCKET if no_tcp and version >= 6 else 0)
327
+ SCOPE_ABSTRACT_UNIX_SOCKET if (no_tcp or loopback) and version >= 6 else 0)
71
328
  size = 24 if attr.scoped else 16 if attr.net else 8
72
329
  ruleset = libc.syscall(SYS_CREATE, ctypes.byref(attr), ctypes.c_size_t(size), ctypes.c_uint32(0))
73
330
  if ruleset < 0:
74
331
  sys.exit(f'landlock.py: create_ruleset errno {ctypes.get_errno()}')
75
- for place in reads:
332
+ started, count = time.monotonic(), 0
333
+ def add(place, rights=None):
334
+ nonlocal count
76
335
  try:
77
336
  fd = os.open(place, os.O_PATH | os.O_CLOEXEC)
78
337
  except OSError:
79
- continue
338
+ return
80
339
  # A file takes file rights only.
81
- rights = handled if os.path.isdir(place) else EXECUTE | READ_FILE
340
+ if rights is None:
341
+ rights = reading if os.path.isdir(place) else EXECUTE | READ_FILE
342
+ if not os.path.isdir(place):
343
+ rights &= EXECUTE | READ_FILE | (1 << 1) | (1 << 14)
82
344
  rule = PathBeneath(rights, fd)
83
345
  if libc.syscall(SYS_ADD, ctypes.c_int(ruleset), ctypes.c_int(1), ctypes.byref(rule), ctypes.c_uint32(0)) < 0:
84
346
  sys.exit(f'landlock.py: add_rule {place} errno {ctypes.get_errno()}')
85
347
  os.close(fd)
348
+ count += 1
349
+ # The directories that hold an excluded path, up to the root.
350
+ def holders_of(excluded):
351
+ holders = set()
352
+ for path in excluded:
353
+ at = os.path.dirname(path)
354
+ while at not in holders and at != os.path.dirname(at):
355
+ holders.add(at); at = os.path.dirname(at)
356
+ return holders
357
+ # `rights` on every entry beneath `directory` but the excluded ones.
358
+ def grant(directory, excluded, holders, rights):
359
+ # Links are not followed: what they lead to is granted on its own or not at all.
360
+ try:
361
+ entries = list(os.scandir(directory))
362
+ except OSError:
363
+ return
364
+ for entry in entries:
365
+ if entry.path in excluded or entry.is_symlink():
366
+ continue
367
+ if entry.path in holders:
368
+ grant(entry.path, excluded, holders, rights)
369
+ else:
370
+ add(entry.path, rights)
371
+ holders = holders_of(hidden)
372
+ for place in reads:
373
+ place = os.path.normpath(place)
374
+ if place in holders and os.path.isdir(place):
375
+ add(place, READ_DIR)
376
+ grant(place, hidden, holders, reading)
377
+ else:
378
+ add(place)
379
+ if deny_write:
380
+ grant('/', deny_write, holders_of(deny_write), WRITE)
381
+ for place in writes:
382
+ add(place, WRITE)
383
+ state = {'rules': count, 'ms': round((time.monotonic() - started) * 1000, 1)}
384
+ # Opened before the ruleset applies, so a write rule cannot refuse it later; not inherited.
385
+ stats_fd = os.open(stats, os.O_WRONLY | os.O_CREAT | os.O_TRUNC | os.O_CLOEXEC, 0o600) if stats else None
386
+ def report():
387
+ if stats_fd is not None:
388
+ data = json.dumps(state).encode()
389
+ os.ftruncate(stats_fd, 0)
390
+ os.pwrite(stats_fd, data, 0)
391
+ report()
86
392
  if libc.prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0:
87
393
  sys.exit(f'landlock.py: no_new_privs errno {ctypes.get_errno()}')
88
394
  if libc.syscall(SYS_RESTRICT, ctypes.c_int(ruleset), ctypes.c_uint32(0)) < 0:
89
395
  sys.exit(f'landlock.py: restrict_self errno {ctypes.get_errno()}')
90
396
  os.close(ruleset)
91
397
  libc.prctl(PR_SET_CHILD_SUBREAPER, 1, 0, 0, 0)
398
+ # The command's process sends its filter's listener back over this pair.
399
+ if loopback:
400
+ state['supervisor'] = 'not started'
401
+ report()
402
+ parent_end, child_end = socket.socketpair()
92
403
  pid = os.fork()
93
404
  if pid == 0:
94
405
  os.setpgid(0, 0)
406
+ if loopback:
407
+ parent_end.close()
408
+ try:
409
+ if fault == 'unavailable':
410
+ raise OSError('test fault')
411
+ listener = install_filter()
412
+ socket.send_fds(child_end, [b'x'], [listener])
413
+ os.close(listener)
414
+ child_end.close()
415
+ except OSError as e:
416
+ # Never run the command unsupervised.
417
+ sys.stderr.write(f'landlock.py: network supervisor unavailable: {e}\n')
418
+ os._exit(125)
95
419
  try:
96
420
  os.execvp(cmd[0], cmd)
97
421
  except OSError as e:
@@ -109,8 +433,28 @@ def main(argv):
109
433
  pass
110
434
  for number in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP):
111
435
  signal.signal(number, forward)
436
+ supervisor = None
437
+ if loopback:
438
+ child_end.close()
439
+ try:
440
+ _, fds, _, _ = socket.recv_fds(parent_end, 1, 1)
441
+ except OSError:
442
+ fds = []
443
+ parent_end.close()
444
+ if fds:
445
+ state['supervisor'] = 'running'
446
+ supervisor = threading.Thread(target=supervise, args=(fds[0], state, writes, fault == 'die'), daemon=True)
447
+ supervisor.start()
448
+ else:
449
+ state['supervisor'] = 'unavailable'
112
450
  _, status = os.waitpid(pid, 0)
113
451
  stop_left()
452
+ # Every filtered process is gone: the supervisor sees its listener hang up.
453
+ if supervisor:
454
+ supervisor.join(5)
455
+ if supervisor.is_alive():
456
+ state['supervisor'] = 'did not end with the command'
457
+ report()
114
458
  code = os.waitstatus_to_exitcode(status)
115
459
  if code >= 0:
116
460
  return code
@@ -2,7 +2,8 @@
2
2
  // The recipe that made a receipt, by the qualification it names.
3
3
  const recipes={'npm-typescript-direct-v1':'direct TypeScript recipe','npm-tests-direct-v1':'direct test recipe',
4
4
  'node-script-v1':'general recipe','pnpm-tsc-v1':'pnpm TypeScript recipe','yarn-workspace-tsc-v1':'Yarn workspace TypeScript recipe',
5
- 'typescript-noemit-v1':'npm TypeScript recipe','sandbox-v1':'sandboxed recipe'};
5
+ 'typescript-noemit-v1':'npm TypeScript recipe','sandbox-v1':'sandboxed recipe',
6
+ 'sandbox-v2':'sandboxed recipe, git-ignored files hidden'};
6
7
  export const recipeLabel=recipe=>recipes[recipe]??null;
7
8
  export function checkLabel(row) {
8
9
  return row.result==='FAIL'?'FAILED':row.freshness;
@@ -1,10 +1,10 @@
1
- // Python projects for `redue init`: typecheck and lint checks that run mypy,
2
- // pyright, ty or ruff from the project's .venv under the sandboxed recipe
1
+ // Python projects for `redue init`: typecheck, lint and test checks that run
2
+ // mypy, pyright, ty, ruff or pytest from the project's .venv under the sandboxed recipe
3
3
  // (sandbox.mjs). A tool is proposed when it is installed in .venv/bin. Its
4
4
  // arguments are the ones the project runs it with, read from CI workflows,
5
5
  // the Makefile, tox, nox, a justfile and shell scripts; without one, the
6
- // tool's own default (`ruff check`, `ty check`, `pyright`, and `mypy` with
7
- // its configured files or `.`).
6
+ // tool's own default (`ruff check`, `ty check`, `pyright`, `pytest`, and
7
+ // `mypy` with its configured files or `.`).
8
8
  import fs from 'node:fs';
9
9
  import path from 'node:path';
10
10
  import {realObservedPath} from './path-identity.mjs';
@@ -15,7 +15,7 @@ const exists=file=>{try{fs.lstatSync(file);return true;}catch{return false;}};
15
15
  const executable=file=>{try{fs.accessSync(file,fs.constants.X_OK);return fs.statSync(file).isFile();}catch{return false;}};
16
16
  // Each tool, its kind, and the subcommand it runs as a check.
17
17
  export const pythonTools=[{tool:'mypy',kind:'typecheck',sub:[]},{tool:'pyright',kind:'typecheck',sub:[]},
18
- {tool:'ty',kind:'typecheck',sub:['check']},{tool:'ruff',kind:'lint',sub:['check']}];
18
+ {tool:'ty',kind:'typecheck',sub:['check']},{tool:'ruff',kind:'lint',sub:['check']},{tool:'pytest',kind:'test',sub:[]}];
19
19
  // The text of a TOML or INI section, up to the next section header.
20
20
  const section=(text,header)=>{const at=text.split(/\r?\n/).findIndex(line=>line.trim()===header);
21
21
  if(at<0)return null;const rest=text.split(/\r?\n/).slice(at+1);const end=rest.findIndex(line=>/^\s*\[/.test(line));
@@ -27,7 +27,9 @@ const wrappers=[/^uv run(?: --(?:frozen|locked|no-sync|offline|quiet|all-extras|
27
27
  /^\$\{?[A-Za-z_]+\}?(?=[A-Za-z])/];
28
28
  // A tool's arguments in one line of a script or workflow, or null. The line
29
29
  // must run the tool and its subcommand plainly; arguments with shell syntax,
30
- // or ones that change files or install packages, are not taken.
30
+ // ones that change files or install packages, and pytest's that run a set
31
+ // chosen from its cache (--lf, --ff, --sw, --nf), show the cache, loop
32
+ // (-f), rewrite snapshots or add to earlier coverage data, are not taken.
31
33
  export function toolArgs(line,{tool,sub}){
32
34
  let text=line.trim().replace(/^-\s+/,'').replace(/^run:\s*/,'').replace(/^[@-]+/,'');
33
35
  text=text.split(/\s(?:&&|\|\||;|\|)\s/)[0].replace(/\s+/g,' ').trim();
@@ -35,7 +37,7 @@ export function toolArgs(line,{tool,sub}){
35
37
  const words=text.split(' ');
36
38
  if(words[0]!==tool||JSON.stringify(words.slice(1,1+sub.length))!==JSON.stringify(sub))return null;
37
39
  const args=words.slice(1+sub.length);
38
- if(args.some(arg=>!/^[A-Za-z0-9_./:=,@+%-]+$/.test(arg)||/^--(?:fix|unsafe-fixes|write|install-types|watch)(?:=|$)/.test(arg)||arg.startsWith('..')||arg.startsWith('/')))return null;
40
+ if(args.some(arg=>!/^[A-Za-z0-9_./:=,@+%-]+$/.test(arg)||/^--(?:fix|unsafe-fixes|write|install-types|watch|lf|last-failed|ff|failed-first|sw|sw-skip|sw-reset|stepwise|stepwise-skip|stepwise-reset|nf|new-first|cache-show|looponfail|snapshot-update|update-snapshots|inline-snapshot|cov-append)(?:=|$)/.test(arg)||arg==='-f'||arg.startsWith('..')||arg.startsWith('/')))return null;
39
41
  return args;
40
42
  }
41
43
  // The places a project writes how it runs its tools, in order of preference.