#!/usr/bin/env python3 """One interpreter start for the whole of 99-python3-pyfanotify. Emits `key=value' lines, one per line; the suite reads them with grep. A python3 start on this arm is tens of seconds and every measurement here is a constant read, a struct size or one refused syscall -- microseconds beside the start -- so taking the start ONCE is the difference between a half-minute suite and a ten-minute one. THE TRANSPORT RULES. The runner parses its input line by line, so no value may contain a NEWLINE; the suite splits on the FIRST `=', so a value containing `=' is safe and a key containing one is not. Every key emitted here is `[a-z0-9_]+' and every value is a decimal, a hex-free integer, a path, or one of `yes'/`no'/`none'. THIS PROBE EXITS 0 EVEN WHEN THE IMPORT FAILS. pyfanotify has NO usable pure-Python half: `pyfanotify/__init__.py:19' does `from . import ext' at module scope and lines 21-70 read forty-odd constants off it, so `import pyfanotify' IS the dlopen of `ext.cpython-312.so'. Exiting non-zero there would turn one platform fact into a red `probe-run' plus fifty empty-string mismatches that each name the wrong thing. Instead the failure is reported as `import_ok=no' with the exception on `import_error', the suite reds exactly one case for it, and the behavioural cases are SKIPPED with that reason attached. WHAT THIS PROBE IS ACTUALLY FOR. The ticket's subject is not the packaging: it is whether the wasm32 defconfig's hand-set `CONFIG_FANOTIFY=y' holds at RUNTIME. So the measurements are partitioned into three groups, and the group boundaries are the whole design: 1. WHAT NEEDS NO SYSCALL -- the constants, the struct geometry, the FanoRule type's argument handling. These are true whatever the kernel does, so they are measured unconditionally and asserted against the KERNEL'S OWN HEADER (`include/uapi/linux/fanotify.h'), never against the library's echo of it. A wrong struct layout on a 32-bit arm reads as plausible garbage, which is exactly why the geometry is asserted rather than inferred from a value that came back looking reasonable. 2. THE GATE -- `fanotify_init' and `fanotify_mark', recorded BY ERRNO. The ticket asks for this explicitly: an ENOSYS or an EPERM here is a finding to attribute, not a bare failure to report. The two are measured separately because they are separate syscalls and can disagree. 3. WHAT SITS BEHIND THE GATE -- a mark on a real path, the four event classes, the `/proc/self/fd' readlink, the permission events, the mark removal. None of these can run until group 2 answers, so the probe records WHY they did not rather than pretending they were skipped for no reason. NOTHING HERE TRUSTS `pyfanotify' TO REPORT THE KERNEL. Group 2 goes through the extension because that is the port under test, and the errno it yields is `PyErr_SetFromErrno' over the raw `fanotify_init(2)' return -- `src/ext.c:487' does the call and line 489 converts it with nothing in between. So the errno IS the kernel's, one frame removed. `raw_*' below tries the same two syscalls through `ctypes' as an independent second reading; where ctypes is unavailable on this arm the keys say so rather than going silently absent. """ import errno import os import struct import sys emitted = [] def emit(key, value): emitted.append(key) print('%s=%s' % (key, value)) # FLUSH EVERY LINE. stdout here is a FILE, so Python block-buffers it, and # this probe calls into a C extension that can take the interpreter down # with it -- a wasm indirect-call signature trap arrives as SIGILL, not as # an exception. With buffering on, such a death discards every line emitted # so far, so a crash at the fortieth measurement is indistinguishable from # one before the first: the suite reads an empty `import_ok' and reports # "the extension did not load", which is the wrong finding about the wrong # call. Measured on BRINTOS-2519: `ext.create()' trapped and the whole # run came back as an import failure until this line was added. sys.stdout.flush() def errno_name(n): """`ENOSYS', or the bare number when the platform has no name for it.""" return errno.errorcode.get(n, str(n)) # ------------------------------------------------------------------ import --- # THE PLATFORM GATE. This image's python3 is ONE STATIC BINARY: configure picks # MODULE_BUILDTYPE=static from host_cpu=wasm32, lib-dynload is empty, and every # stdlib extension is linked in (BRINTOS-1476). pyfanotify is not a stdlib # module, so its `ext' is a SIDE MODULE dlopen'd at import, and its CPython API # imports must resolve against that static main. Two of them did not until # BRINTOS-2504 landed: `_PyArg_ParseTuple_SizeT' and # `_PyArg_ParseTupleAndKeywords_SizeT', both PY_SSIZE_T_CLEAN rename targets # (`src/ext.c:5' defines the macro before including Python.h, and the module has # five PyArg_ParseTupleAndKeywords call sites and one PyArg_ParseTuple). try: import pyfanotify emit('import_ok', 'yes') emit('import_error', 'none') except BaseException as e: emit('import_ok', 'no') emit('import_error', '%s: %s' % (type(e).__name__, e)) emit('emitted_count', len(emitted) + 1) emit('probe_done', 'yes') sys.exit(0) ext = pyfanotify.ext # ---------------------------------------------------------------- identity --- emit('version', getattr(pyfanotify, '__version__', '')) emit('package_file', getattr(pyfanotify, '__file__', '')) emit('ext_file', getattr(ext, '__file__', '')) # `pyfanotify' is a PACKAGE whose Python half is a facade; `ext' is the # extension. Asserting both shapes separates "the package vanished" from "the # extension vanished", which fail at the same ImportError otherwise. emit('package_is_package', 'yes' if hasattr(pyfanotify, '__path__') else 'no') emit('ext_is_extension', 'yes' if str(getattr(ext, '__file__', '')).endswith('.so') else 'no') for _n in ('Fanotify', 'FanotifyClient', 'FanoRule', 'FanotifyData'): emit('has_%s' % _n.lower(), 'yes' if hasattr(pyfanotify, _n) else 'no') emit('all_tuple', ','.join(getattr(pyfanotify, '__all__', ()))) # ---------------------------------------------------------- group 1: values --- # EVERY EXPECTED VALUE THE SUITE COMPARES AGAINST IS THE KERNEL'S, read out of # `include/uapi/linux/fanotify.h' at the pinned LINUX_BRINTOS_SRCREV rather than # out of this library. The extension obtains each one through # PyModule_AddIntMacro over the same header at COMPILE time, so a disagreement # means the sysroot header the recipe compiled against is not the header the # running kernel was built from -- which is a real and otherwise silent way for a # port to be subtly wrong on a fresh arm. _CONSTS = ( 'FAN_ACCESS', 'FAN_MODIFY', 'FAN_ATTRIB', 'FAN_CLOSE_WRITE', 'FAN_CLOSE_NOWRITE', 'FAN_OPEN', 'FAN_MOVED_FROM', 'FAN_MOVED_TO', 'FAN_CREATE', 'FAN_DELETE', 'FAN_DELETE_SELF', 'FAN_MOVE_SELF', 'FAN_OPEN_EXEC', 'FAN_Q_OVERFLOW', 'FAN_OPEN_PERM', 'FAN_ACCESS_PERM', 'FAN_OPEN_EXEC_PERM', 'FAN_EVENT_ON_CHILD', 'FAN_RENAME', 'FAN_ONDIR', 'FAN_CLOSE', 'FAN_MOVE', 'FAN_CLOEXEC', 'FAN_NONBLOCK', 'FAN_CLASS_NOTIF', 'FAN_CLASS_CONTENT', 'FAN_CLASS_PRE_CONTENT', 'FAN_UNLIMITED_QUEUE', 'FAN_UNLIMITED_MARKS', 'FAN_ENABLE_AUDIT', 'FAN_REPORT_TID', 'FAN_REPORT_FID', 'FAN_REPORT_DIR_FID', 'FAN_REPORT_NAME', 'FAN_REPORT_DFID_NAME', 'FAN_MARK_ADD', 'FAN_MARK_REMOVE', 'FAN_MARK_DONT_FOLLOW', 'FAN_MARK_ONLYDIR', 'FAN_MARK_IGNORED_MASK', 'FAN_MARK_IGNORED_SURV_MODIFY', 'FAN_MARK_FLUSH', 'FAN_MARK_INODE', 'FAN_MARK_MOUNT', 'FAN_MARK_FILESYSTEM', 'FANOTIFY_METADATA_VERSION', 'FAN_ALLOW', 'FAN_DENY', 'FAN_AUDIT', 'FAN_NOFD', 'AT_FDCWD', ) for _c in _CONSTS: emit('c_%s' % _c.lower(), getattr(pyfanotify, _c, 'MISSING')) # THE `#ifdef' SHIMS. `src/ext.c' publishes 0 for every constant its build-time # header did not define (FAN_ENABLE_AUDIT, FAN_REPORT_*, FAN_OPEN_EXEC*, the # Linux 5.1 dirent events, FAN_RENAME). A 0 is therefore ambiguous by # construction -- "the kernel says 0" and "the header this was compiled against # is older than the kernel" print identically -- so the suite compares each # against the kernel header's own value and a shimmed 0 reds there. This key # counts them in one place so a reviewer sees the shape at a glance rather than # reading fifty rows. emit('shimmed_zero_count', sum(1 for _c in ('FAN_ENABLE_AUDIT', 'FAN_REPORT_TID', 'FAN_REPORT_FID', 'FAN_REPORT_DIR_FID', 'FAN_REPORT_NAME', 'FAN_OPEN_EXEC', 'FAN_OPEN_EXEC_PERM', 'FAN_ATTRIB', 'FAN_MOVED_FROM', 'FAN_MOVED_TO', 'FAN_CREATE', 'FAN_DELETE', 'FAN_DELETE_SELF', 'FAN_MOVE_SELF', 'FAN_RENAME', 'FAN_MARK_INODE', 'FAN_MARK_FILESYSTEM') if getattr(pyfanotify, _c, None) == 0 and _c not in ('FAN_MARK_INODE',))) # ------------------------------------------------- group 1: struct geometry --- # `struct fanotify_event_metadata' is what `read(2)' on the group fd returns, and # a 32-bit arm is exactly where its layout goes wrong quietly: `mask' is # `__aligned_u64', so it sits at offset 8 with SIX bytes of padding in front of # it on a naive 4-byte-aligned reading, and a consumer that packed it at offset 6 # would get plausible-looking garbage rather than an error. Measured here with # the platform's own native alignment so the suite asserts the RUNTIME geometry. # # THREE readings, and they answer different questions. `=' is standard size with # NO alignment -- the WIRE layout the suite parses with -- while `@' is the # platform's natural C layout. Asserting `=' alone would say nothing about the # arm, and an earlier draft of this probe asked `=' for an ALIGNMENT and got the # constant 1 on every platform, which is a column that cannot discriminate. The # x86-64 control is what caught it. _META = struct.Struct('=IBBHQii') emit('meta_size', _META.size) emit('meta_mask_offset', struct.calcsize('=IBBH')) # The property that actually matters: the natural C layout of those fields is # the same 24 bytes as the wire layout, so no padding is inserted anywhere the # kernel does not expect it. emit('meta_size_native', struct.calcsize('@IBBHQii')) # The arm's natural alignment for a `unsigned long long', recorded rather than # demanded. It is 8 on LP64 and 4 on i386 -- which is precisely why the kernel # declares the field `__aligned_u64' instead of `__u64'. Either answer leaves the # 24-byte layout above intact, so this is documentation of the arm, and the case # that reads it reds only on a value that is neither. emit('u64_align_native', struct.calcsize('@BQ') - 8) _RESP = struct.Struct('=ii') emit('resp_size', _RESP.size) emit('resp_size_native', struct.calcsize('@ii')) # The library's own event-name table, which the suite spot-checks: a rename of a # member here would silently change what every consumer's log says. emit('evt_to_str_modify', pyfanotify.evt_to_str(pyfanotify.FAN_MODIFY)) emit('evt_to_str_open_close', pyfanotify.evt_to_str(pyfanotify.FAN_OPEN | pyfanotify.FAN_CLOSE_WRITE)) emit('evt_to_str_zero', pyfanotify.evt_to_str(0)) # ------------------------------------------------------ group 1: FanoRule --- # THE ONE PART OF THE EXTENSION THAT RUNS NO SYSCALL. `FanoRule_init' is a # PyArg_ParseTupleAndKeywords over "y*|Okz*z*z*b" and nothing else, so it # exercises the argument-parsing half of the module -- which is the half # BRINTOS-2504's two missing `_SizeT' names lived in -- without needing a group. # That makes it the strongest evidence available on a kernel that refuses the # syscalls: it proves the dlopen'd code RUNS, not merely that it loaded. try: _r = pyfanotify.FanoRule(name=b'bst-probe', ev_types=pyfanotify.FAN_MODIFY) emit('fanorule_ok', 'yes') emit('fanorule_type', type(_r).__name__) emit('fanorule_error', 'none') except BaseException as e: emit('fanorule_ok', 'no') emit('fanorule_type', '') emit('fanorule_error', '%s: %s' % (type(e).__name__, e)) # A rule with no rule parameter is documented to raise ValueError; one whose # `pids' is not a set/list/tuple is documented to raise TypeError. Both are # asserted so the parser's REFUSAL path is measured too -- an extension whose # argument parsing had regressed would more likely accept everything than reject # everything, and only the refusal cases notice that. try: pyfanotify.FanoRule(name=b'bst-probe-empty') emit('fanorule_noparam', 'accepted') except BaseException as e: emit('fanorule_noparam', type(e).__name__) try: pyfanotify.FanoRule(name=b'bst-probe-pids', pids=1, ev_types=1) emit('fanorule_badpids', 'accepted') except BaseException as e: emit('fanorule_badpids', type(e).__name__) # --------------------------------------------------- group 2: THE GATE ------ # `ext.create()' is a calloc and three field stores -- no syscall -- so it # separates "the module cannot allocate" from "the kernel refuses". Its return # is the context pointer as an int; zero would mean the calloc failed. try: _ctx = ext.create() emit('create_ok', 'yes' if _ctx else 'no') emit('create_error', 'none') except BaseException as e: _ctx = 0 emit('create_ok', 'no') emit('create_error', '%s: %s' % (type(e).__name__, e)) # `ext.mark' FIRST, and on a context whose `fano_fd' is still -1. This is # exactly what `Fanotify.__init__' does at `__init__.py:162' before it ever calls # init, and the library TOLERATES an EBADF there -- so the errno this yields # discriminates three states that would otherwise all read as "fanotify is # broken": # # EBADF the syscall is wired and rejected the fd: fanotify_mark IS reachable # ENOSYS the syscall number is not dispatched at all # EPERM the syscall is wired and the caller lacks CAP_SYS_ADMIN # # Only the first means the kernel has fanotify in the sense the ticket asks # about. Recording the NUMBER rather than a boolean is the ticket's own # instruction, and this is the case it was written for. if _ctx: try: ext.mark(_ctx, pyfanotify.FAN_MARK_ADD, pyfanotify.FAN_MODIFY | pyfanotify.FAN_CLOSE_WRITE, pyfanotify.AT_FDCWD, '') emit('mark_badfd_errno', '0') emit('mark_badfd_errno_name', 'OK') except OSError as e: emit('mark_badfd_errno', e.errno) emit('mark_badfd_errno_name', errno_name(e.errno)) except BaseException as e: emit('mark_badfd_errno', '-1') emit('mark_badfd_errno_name', type(e).__name__) else: emit('mark_badfd_errno', '-1') emit('mark_badfd_errno_name', 'no-context') # `ext.init' -- `fanotify_init(2)' itself, with the flags the library uses. # `src/ext.c:487' makes the call and line 489 is `PyErr_SetFromErrno', so this # errno is the kernel's with nothing in between. _INIT_FLAGS = (pyfanotify.FAN_CLOEXEC | pyfanotify.FAN_NONBLOCK | pyfanotify.FAN_UNLIMITED_QUEUE | pyfanotify.FAN_UNLIMITED_MARKS | pyfanotify.FAN_CLASS_CONTENT) _INIT_O_FLAGS = (os.O_RDONLY | getattr(os, 'O_LARGEFILE', 0) | ext.O_CLOEXEC | getattr(os, 'O_NOATIME', 0)) _fd = -1 _init_why = 'no-context' if _ctx: try: _fd = ext.init(_ctx, _INIT_FLAGS, _INIT_O_FLAGS) _init_why = 'OK' emit('init_errno', '0') emit('init_errno_name', 'OK') emit('init_fd_valid', 'yes' if _fd >= 0 else 'no') except OSError as e: _init_why = errno_name(e.errno) emit('init_errno', e.errno) emit('init_errno_name', _init_why) emit('init_fd_valid', 'no') except BaseException as e: _init_why = type(e).__name__ emit('init_errno', '-1') emit('init_errno_name', _init_why) emit('init_fd_valid', 'no') else: emit('init_errno', '-1') emit('init_errno_name', 'no-context') emit('init_fd_valid', 'no') # THE SAME QUESTION ASKED A SECOND WAY, through the class the ticket names. # `Fanotify()' is an `mp.Process' subclass whose `__init__' runs the mark/init # pair above; upstream has a FIRST-CLASS path for the ENOSYS case, appending # `: No fanotify!' to the strerror at `__init__.py:167'. Asserting that shape # is what turns "the port does not work here" into "the port reports this # platform's state in the library's own documented words". try: _f = pyfanotify.Fanotify() emit('ctor_errno', '0') emit('ctor_errno_name', 'OK') emit('ctor_strerror', '') except OSError as e: emit('ctor_errno', e.errno) emit('ctor_errno_name', errno_name(e.errno)) emit('ctor_strerror', (e.strerror or '').replace('\n', ' ')) except BaseException as e: emit('ctor_errno', '-1') emit('ctor_errno_name', type(e).__name__) emit('ctor_strerror', str(e).replace('\n', ' ')) # AN INDEPENDENT SECOND READING, so the errno above is not taken on the # extension's word alone. ctypes needs libffi and a working dlopen on this arm, # so its ABSENCE is reported rather than left to look like a missing key -- a # detector that cannot run must say so, or its silence reads as a measurement. try: import ctypes _libc = ctypes.CDLL(None, use_errno=True) ctypes.set_errno(0) _rc = _libc.syscall(ctypes.c_long(300), ctypes.c_long(0), ctypes.c_long(0)) emit('raw_init_rc', _rc) emit('raw_init_errno', ctypes.get_errno() if _rc == -1 else 0) emit('raw_init_errno_name', errno_name(ctypes.get_errno()) if _rc == -1 else 'OK') except BaseException as e: emit('raw_init_rc', 'unavailable') emit('raw_init_errno', 'unavailable') emit('raw_init_errno_name', '%s' % type(e).__name__) # ------------------------------------------- group 3: BEHIND THE GATE ------- # The event flow the ticket asks for -- a tmpfs path marked for FAN_OPEN, # FAN_ACCESS, FAN_MODIFY and FAN_CLOSE_WRITE, then opened, read, written and # closed, each event asserted to arrive with a usable fd whose # `/proc/self/fd/' readlink names the file that caused it; the FAN_OPEN_PERM # ALLOW and DENY pair; the mark removal; the ENOENT on a mark of a missing path. # NONE of it can run without a group fd. `gate_open' is the one key the suite # branches on, and `gate_reason' is what it attaches to every skip -- so a reader # of the report is told WHY fifty cases did not run, in one place, rather than # having to infer it. _gate = _fd >= 0 emit('gate_open', 'yes' if _gate else 'no') emit('gate_reason', 'none' if _gate else 'fanotify_init -> %s' % _init_why) if _gate: import tempfile _d = tempfile.mkdtemp(prefix='bst-pyfanotify-') _p = os.path.join(_d, 'watched') with open(_p, 'wb') as _fh: _fh.write(b'seed\n') _mask = (pyfanotify.FAN_OPEN | pyfanotify.FAN_ACCESS | pyfanotify.FAN_MODIFY | pyfanotify.FAN_CLOSE_WRITE) try: ext.mark(_ctx, pyfanotify.FAN_MARK_ADD, _mask, pyfanotify.AT_FDCWD, _p) emit('mark_path_ok', 'yes') emit('mark_path_error', 'none') except OSError as e: emit('mark_path_ok', 'no') emit('mark_path_error', errno_name(e.errno)) # Marking a path that does not exist is documented to raise carrying ENOENT. try: ext.mark(_ctx, pyfanotify.FAN_MARK_ADD, _mask, pyfanotify.AT_FDCWD, os.path.join(_d, 'no-such-file')) emit('mark_missing_errno_name', 'OK') except OSError as e: emit('mark_missing_errno_name', errno_name(e.errno)) # Drive the four event classes, then read the queue once. The group fd is # FAN_NONBLOCK, so a read with nothing queued raises EAGAIN rather than # hanging -- which is what makes this safe to run inside a suite bound. with open(_p, 'r+b') as _fh: _fh.read() _fh.write(b'more\n') _fh.flush() _seen = 0 _names = [] _fds = 0 _vers = -1 try: _buf = os.read(_fd, _META.size * 64) except OSError as e: _buf = b'' emit('read_errno_name', errno_name(e.errno)) else: emit('read_errno_name', 'OK') _off = 0 while _off + _META.size <= len(_buf): _elen, _vers, _res, _mlen, _emask, _efd, _epid = _META.unpack_from( _buf, _off) _off += _elen if _elen else _META.size _seen |= _emask if _efd >= 0: _fds += 1 try: _names.append(os.readlink('/proc/self/fd/%d' % _efd)) except OSError: _names.append('') os.close(_efd) emit('events_mask_seen', _seen) emit('events_with_fd', _fds) emit('events_readlink_hit', 'yes' if any(n == _p for n in _names) else 'no') emit('events_vers', _vers) # Remove the mark; no further event must be delivered for the same actions. try: ext.mark(_ctx, pyfanotify.FAN_MARK_REMOVE, _mask, pyfanotify.AT_FDCWD, _p) emit('unmark_ok', 'yes') except OSError as e: emit('unmark_ok', errno_name(e.errno)) with open(_p, 'r+b') as _fh: _fh.read() _fh.write(b'after\n') _fh.flush() try: _after = os.read(_fd, _META.size * 64) except OSError: _after = b'' emit('events_after_unmark', len(_after)) # The permission class. It needs CONFIG_FANOTIFY_ACCESS_PERMISSIONS, which # in turn needs CONFIG_SECURITY -- neither is in the wasm32 defconfig -- so # `fanotify_mark' rejects FANOTIFY_PERM_EVENTS with EINVAL at # `fs/notify/fanotify/fanotify_user.c:1735'. Recorded by errno for the same # reason as the gate above: EINVAL here is a CONFIG fact, not a port fault. try: ext.mark(_ctx, pyfanotify.FAN_MARK_ADD, pyfanotify.FAN_OPEN_PERM, pyfanotify.AT_FDCWD, _p) emit('mark_perm_errno_name', 'OK') except OSError as e: emit('mark_perm_errno_name', errno_name(e.errno)) try: os.close(_fd) except OSError: pass else: for _k in ('mark_path_ok', 'mark_path_error', 'mark_missing_errno_name', 'read_errno_name', 'events_mask_seen', 'events_with_fd', 'events_readlink_hit', 'events_vers', 'unmark_ok', 'events_after_unmark', 'mark_perm_errno_name'): emit(_k, 'gated') # ------------------------------------------------- the upstream py3.12 break --- # `FanotifyClient._recv_data' calls `array.array.fromstring' (`__init__.py:487'), # which CPython REMOVED in 3.9. So even on a kernel that answered every syscall # above, the client's event path would raise AttributeError on this # interpreter. It is upstream's defect and not this platform's, it is asserted # from the INSTALLED source rather than by provoking it (there is no group to # provoke it with), and per `no-public-upstream-reports' we fix such things in # our own tree rather than reporting them -- so the case exists to stop a reader # concluding the port is whole when the gate finally opens. try: import array emit('array_has_fromstring', 'yes' if hasattr(array.array, 'fromstring') else 'no') except BaseException: emit('array_has_fromstring', 'unknown') _src = getattr(pyfanotify, '__file__', '') try: with open(_src, 'r') as _fh: _text = _fh.read() emit('client_uses_fromstring', 'yes' if 'fromstring' in _text else 'no') except BaseException: emit('client_uses_fromstring', 'unknown') emit('emitted_count', len(emitted) + 2) emit('probe_done', 'yes') sys.exit(0)