diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b8b29b2..e46c640 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -244,7 +244,8 @@ jobs: for rpm in rpms/plugin-hostd-tests-[0-9]*.${{ matrix.arch }}.rpm; do rpm -qlp "$rpm" rpm -qp --qf '[%{FILEMODES:perms} %{FILENAMES}\n]' "$rpm" | grep -v -e '^d' -e '/\.build-id/' | cut -d' ' -f2 | sort > files.txt - printf '%s\n' /usr/libexec/plugin-hostd/tests/fake-host /usr/share/licenses/plugin-hostd-tests/COPYING | sort > expected.txt + printf '%s\n' /usr/libexec/plugin-hostd/tests/fake-host /usr/libexec/plugin-hostd/tests/lib/protocol.sh /usr/libexec/plugin-hostd/tests/lib/plugin_hostd_client.py \ + /usr/share/licenses/plugin-hostd-tests/COPYING | sort > expected.txt diff -u expected.txt files.txt # tied to the same build: the requirement names the exact version-release and the isa of this very package case "${{ matrix.arch }}" in x86_64) isa=x86-64 ;; aarch64) isa=aarch-64 ;; *) echo "no isa for ${{ matrix.arch }}"; exit 1 ;; esac @@ -296,6 +297,9 @@ jobs: set -euo pipefail chroot /tmp/clean-root /usr/libexec/plugin-hostd/tests/fake-host -L | tee layout.txt test -s layout.txt + # and the protocol clients it ships load + chroot /tmp/clean-root bash -c '. /usr/libexec/plugin-hostd/tests/lib/protocol.sh && type protocol_send protocol_expect protocol_wait_ready send' + chroot /tmp/clean-root python3 -c 'import sys; sys.path.insert(0, "/usr/libexec/plugin-hostd/tests/lib"); import plugin_hostd_client' chroot /tmp/clean-root env PLUGIN_HOSTD=/usr/bin/plugin-hostd PROBE=/usr/bin/plugin-hostd-probe FAKE_HOST=/usr/libexec/plugin-hostd/tests/fake-host \ PLUGIN_HOSTD_PROTOCOL=/usr/share/plugin-hostd/protocol.json python3 /smoke/tests/daemon_test.py @@ -415,6 +419,8 @@ jobs: dpkg -L plugin-hostd | grep -qx /usr/share/plugin-hostd/mod-host.json dpkg -L plugin-hostd-tests | tee tests.txt grep -qx /usr/libexec/plugin-hostd/tests/fake-host tests.txt + grep -qx /usr/libexec/plugin-hostd/tests/lib/protocol.sh tests.txt + grep -qx /usr/libexec/plugin-hostd/tests/lib/plugin_hostd_client.py tests.txt dpkg -s libmod-host-protocol0 | grep '^Status: install ok installed' dpkg-deb -f debs/plugin-hostd_*.deb Depends | tee depends.txt grep -qF 'libmod-host-protocol0 (>= 0.1.0-6~fmx)' depends.txt diff --git a/Makefile b/Makefile index 0641046..af03f88 100644 --- a/Makefile +++ b/Makefile @@ -160,10 +160,12 @@ test-format-coverage: python3 tests/format_coverage.py --contract-dir $(CONTRACTDIR) --major $(CONTRACT_SCHEMA_MAJOR) lv2 python3 tests/format_coverage.py --contract-dir $(CONTRACTDIR) --major $(CONTRACT_SCHEMA_MAJOR) --self-test -# the fake worker, for a program that drives plugin-hostd: away from $(BINDIR), where nothing runs it by chance +# the fake worker and the protocol clients (lib/protocol.sh, lib/plugin_hostd_client.py), for a program that drives plugin-hostd: away from $(BINDIR), where nothing runs it by chance install_tests: $(FAKE) install -d $(DESTDIR)$(LIBEXECDIR)/plugin-hostd/tests install -m 755 $(FAKE) $(DESTDIR)$(LIBEXECDIR)/plugin-hostd/tests/fake-host + install -d $(DESTDIR)$(LIBEXECDIR)/plugin-hostd/tests/lib + install -m 644 tests/lib/protocol.sh tests/lib/plugin_hostd_client.py $(DESTDIR)$(LIBEXECDIR)/plugin-hostd/tests/lib install_man: install -d $(DESTDIR)$(MANDIR) @@ -254,9 +256,14 @@ clean: # the daemon against workers that are not plugin hosts at all (tests/fake-host speaks the same protocol and can be # made to die on cue): placement, forwarding, ledger, replay, attribution, the storm bound; no jack, no plugin -test: test-pin test-transforms test-format-coverage test-describe test-relay test-alloc test-format test-daemon test-verbs-declared check-rendered check-generated check-schema test-probe-schema test-vocabulary test-probe test-contract test-consumer test-perturbation check-readme check-own-host-page check-no-pins check-ci-guards test-mod-host-json test-socket check-verb-table check-preset-schema test-install-schemas +test: test-protocol-lib test-pin test-transforms test-format-coverage test-describe test-relay test-alloc test-format test-daemon test-verbs-declared check-rendered check-generated check-schema test-probe-schema test-vocabulary test-probe test-contract test-consumer test-perturbation check-readme check-own-host-page check-no-pins check-ci-guards test-mod-host-json test-socket check-verb-table check-preset-schema test-install-schemas MOD_HOST_DIR=$(MOD_HOST_DIR) python3 tests/verbs_contract.py +# the protocol clients plugin-hostd-tests ships (tests/lib/), against a peer that frames by NUL, and their sabotage +.PHONY: test-protocol-lib +test-protocol-lib: + python3 tests/protocol_lib.py + # the same without the verb table, which is read from a mod-host checkout's README test-daemon: $(PROG) $(FAKE) PLUGIN_HOSTD=./$(PROG) FAKE_HOST=./$(FAKE) python3 tests/daemon_test.py diff --git a/debian/plugin-hostd-tests.install b/debian/plugin-hostd-tests.install index a2b965f..383632a 100644 --- a/debian/plugin-hostd-tests.install +++ b/debian/plugin-hostd-tests.install @@ -1 +1,3 @@ usr/libexec/plugin-hostd/tests/fake-host +usr/libexec/plugin-hostd/tests/lib/protocol.sh +usr/libexec/plugin-hostd/tests/lib/plugin_hostd_client.py diff --git a/debian/rules b/debian/rules index 6176628..cb20fe1 100755 --- a/debian/rules +++ b/debian/rules @@ -19,3 +19,8 @@ override_dh_auto_test: ifeq (,$(filter nocheck,$(DEB_BUILD_OPTIONS))) $(MAKE) test-daemon check-generated check-rendered endif + +# the protocol clients are sourced and imported, never run: no execute bit +override_dh_fixperms: + dh_fixperms + chmod 0644 debian/plugin-hostd-tests/usr/libexec/plugin-hostd/tests/lib/protocol.sh debian/plugin-hostd-tests/usr/libexec/plugin-hostd/tests/lib/plugin_hostd_client.py diff --git a/packaging/plugin-hostd.spec b/packaging/plugin-hostd.spec index ad3c6b5..cc6b26a 100644 --- a/packaging/plugin-hostd.spec +++ b/packaging/plugin-hostd.spec @@ -128,6 +128,8 @@ make test-daemon check-generated check-rendered %files tests %license COPYING %{_libexecdir}/plugin-hostd/tests/fake-host +%{_libexecdir}/plugin-hostd/tests/lib/protocol.sh +%{_libexecdir}/plugin-hostd/tests/lib/plugin_hostd_client.py %files devel %license COPYING diff --git a/tests/harness.py b/tests/harness.py index 83fe4d5..dd0e853 100644 --- a/tests/harness.py +++ b/tests/harness.py @@ -1,7 +1,10 @@ # SPDX-License-Identifier: GPL-3.0-or-later # The daemon under test and a controller for it: a command socket, a feedback socket read on a thread, and # helpers that wait for what the daemon does instead of sleeping and hoping. -import json, os, re, shutil, signal, socket, subprocess, tempfile, threading, time +import json, os, re, shutil, signal, socket, subprocess, sys, tempfile, threading, time + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "lib")) +import plugin_hostd_client # The protocol the daemon declares, read from the file a consumer reads: the tree's own, or PLUGIN_HOSTD_PROTOCOL (the # installed package's /usr/share/plugin-hostd/protocol.json, in the smoke test of a package). No value of it is @@ -116,6 +119,7 @@ def __init__(self, exe, worker, clap_worker=None, conf=None, env=None, feedback= self.events = [] self.lock = threading.Lock() self.sock = socket.create_connection(("127.0.0.1", self.cmd_port), timeout=10) + self.client = plugin_hostd_client.Client(self.sock) self.fb = socket.create_connection(("127.0.0.1", self.fb_port), timeout=10) if feedback else None if self.fb: self.fb.settimeout(None) # silence on the feedback port is not an error @@ -142,14 +146,10 @@ def _read_feedback(self): self.events.append(line.decode()) def send(self, msg): - self.sock.sendall(msg.encode() + b"\0") - buf = b"" - while not buf.endswith(b"\0"): - c = self.sock.recv(65536) - if not c: - raise Fail("daemon closed on " + msg) - buf += c - return buf[:-1].decode() + try: + return self.client.send(msg) + except ConnectionError: + raise Fail("daemon closed on " + msg) def ok(self, msg, expect=None): r = self.send(msg) diff --git a/tests/lib/plugin_hostd_client.py b/tests/lib/plugin_hostd_client.py new file mode 100644 index 0000000..9f2b1b5 --- /dev/null +++ b/tests/lib/plugin_hostd_client.py @@ -0,0 +1,57 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +"""The mod-host line protocol from Python, the one client every test of plugin-hostd, omx-clap-host and the controllers +imports: a command is one line ended by a NUL, a reply is one NUL-ended line. Installed by plugin-hostd-tests as +/usr/libexec/plugin-hostd/tests/lib/plugin_hostd_client.py, and in this tree as tests/lib/plugin_hostd_client.py. + + c = Client(sock) a connected socket, or Client.connect(port) + c.send(line) the reply; ConnectionError when the peer closes, socket.timeout when none comes in time + c.try_send(line, timeout) (reply, None), or (None, 'closed' | 'timeout' | 'oserror '): a test that expects a death + c.expect(line, wanted) the reply, AssertionError when it is not wanted +""" +import socket +import time + + +class Client: + def __init__(self, sock, timeout=10.0): + self.sock = sock + self.timeout = timeout + self.buf = b"" + + @classmethod + def connect(cls, port, host="127.0.0.1", timeout=10.0): + return cls(socket.create_connection((host, port), timeout=timeout), timeout) + + def send(self, line, timeout=None): + self.sock.sendall(line.encode() + b"\0") + end = time.monotonic() + (self.timeout if timeout is None else timeout) + while b"\0" not in self.buf: + left = end - time.monotonic() + if left <= 0: + raise socket.timeout("no reply to %r" % line) + self.sock.settimeout(left) + chunk = self.sock.recv(65536) + if not chunk: + raise ConnectionError("the peer closed on " + line) + self.buf += chunk + reply, self.buf = self.buf.split(b"\0", 1) + return reply.decode(errors="replace") + + def try_send(self, line, timeout=None): + try: + return self.send(line, timeout), None + except socket.timeout: + return None, "timeout" + except ConnectionError: + return None, "closed" + except OSError as e: + return None, "oserror %s" % e + + def expect(self, line, wanted): + reply = self.send(line) + if reply != wanted: + raise AssertionError("%s -> %r, wanted %r" % (line, reply, wanted)) + return reply + + def close(self): + self.sock.close() diff --git a/tests/lib/protocol.sh b/tests/lib/protocol.sh new file mode 100644 index 0000000..62c0c35 --- /dev/null +++ b/tests/lib/protocol.sh @@ -0,0 +1,43 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# The mod-host line protocol from a shell, the one client every test of plugin-hostd, omx-clap-host and the +# controllers sources: a command is one line ended by a NUL, a reply is one NUL-ended line. Installed by +# plugin-hostd-tests as /usr/libexec/plugin-hostd/tests/lib/protocol.sh, and in this tree as tests/lib/protocol.sh. +# +# protocol_send FD SECONDS LINE the reply to LINE on the open descriptor FD, on stdout +# protocol_expect FD SECONDS LINE WANTED 0 when the reply is WANTED, 1 and a line on stderr when it is not +# protocol_wait_ready LOG [TRIES] 0 when LOG holds the readiness marker within TRIES tenths of a second (50) +# send LINE protocol_send on PROTOCOL_FD (3) for PROTOCOL_TIMEOUT (5) seconds +# +# A script that talks to one socket opens it on a descriptor (exec 3<>/dev/tcp/127.0.0.1/$port), sets PROTOCOL_FD and +# PROTOCOL_TIMEOUT if it differs from the defaults, and calls send; one that talks to several calls protocol_send. + +# a worker prints a line ending with this once its socket accepts (protocol.h PHD_WORKER_READY_MARKER) +PROTOCOL_READY_MARKER=${PROTOCOL_READY_MARKER:-ready!} + +protocol_send() { + local reply= + printf '%s\0' "$3" >&"$1" + IFS= read -r -d '' -t "$2" reply <&"$1" + printf '%s\n' "$reply" +} + +protocol_expect() { + local reply + reply=$(protocol_send "$1" "$2" "$3") + [ "$reply" = "$4" ] && return 0 + printf 'protocol_expect: %s -> %s, wanted %s\n' "$3" "$reply" "$4" >&2 + return 1 +} + +protocol_wait_ready() { + local i + for ((i = 0; i < ${2:-50}; i++)); do + grep -q -F "$PROTOCOL_READY_MARKER" "$1" 2>/dev/null && return 0 + sleep 0.1 + done + grep -q -F "$PROTOCOL_READY_MARKER" "$1" 2>/dev/null +} + +send() { + protocol_send "${PROTOCOL_FD:-3}" "${PROTOCOL_TIMEOUT:-5}" "$1" +} diff --git a/tests/protocol_lib.py b/tests/protocol_lib.py new file mode 100644 index 0000000..abdf024 --- /dev/null +++ b/tests/protocol_lib.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-3.0-or-later +# The two protocol clients plugin-hostd-tests ships (tests/lib/protocol.sh, tests/lib/plugin_hostd_client.py) against a +# peer that answers in NUL-ended replies, split across writes, then its sabotage: a copy of each client that frames by +# newline instead of NUL must fail the same checks, so the checks can tell a client that frames wrongly. +import os, re, socket, subprocess, sys, tempfile, threading, time + +HERE = os.path.dirname(os.path.abspath(__file__)) +LIB = os.path.join(HERE, "lib") + + +def serve(srv, stop): + """echo "resp " as one reply, written in two pieces; "mute" is never answered; "bye" closes""" + while not stop.is_set(): + try: + c, _ = srv.accept() + except OSError: + return + buf = b"" + while True: + try: + d = c.recv(4096) + except OSError: + break + if not d: + break + buf += d + while b"\0" in buf: + line, buf = buf.split(b"\0", 1) + if line == b"mute": + continue + if line == b"bye": + c.close() + break + reply = b"resp " + line + b"\0" + c.sendall(reply[:3]) + time.sleep(0.02) + c.sendall(reply[3:]) + c.close() + + +def shell(lib, port, script): + """a bash that opens the peer on descriptor 3, sources `lib` and runs `script`; its stdout, stderr and status""" + body = 'exec 3<>/dev/tcp/127.0.0.1/%d\n. %s\n%s' % (port, lib, script) + p = subprocess.run(["bash", "-c", body], capture_output=True, text=True, timeout=30) + return p.stdout, p.stderr, p.returncode + + +def check_shell(lib, port, failures): + out, _, rc = shell(lib, port, 'PROTOCOL_TIMEOUT=2; send "add x 0"; protocol_send 3 2 "a b"') + if out != "resp add x 0\nresp a b\n": + failures.append("shell send: %r" % out) + _, _, rc = shell(lib, port, 'protocol_expect 3 2 "ping" "resp ping"') + if rc != 0: + failures.append("shell expect of the right reply returned %d" % rc) + _, err, rc = shell(lib, port, 'protocol_expect 3 2 "ping" "resp pong"') + if rc == 0 or "resp ping" not in err: + failures.append("shell expect of the wrong reply returned %d, said %r" % (rc, err)) + with tempfile.NamedTemporaryFile("w", suffix=".log") as log: + _, _, rc = shell(lib, port, 'protocol_wait_ready %s 2' % log.name) + if rc == 0: + failures.append("a log without the marker was ready") + log.write("starting\nplugin-hostd ready!\n") + log.flush() + _, _, rc = shell(lib, port, 'protocol_wait_ready %s 2' % log.name) + if rc != 0: + failures.append("a log with the marker was not ready") + + +def check_python(libdir, port, failures): + sys.path.insert(0, libdir) + sys.modules.pop("plugin_hostd_client", None) + import plugin_hostd_client as m + c = m.Client.connect(port) + if c.send("add x 0") != "resp add x 0": + failures.append("python send") + if c.expect("ping", "resp ping") != "resp ping": + failures.append("python expect") + try: + c.expect("ping", "resp pong") + failures.append("python expect of the wrong reply raised nothing") + except AssertionError: + pass + if c.try_send("mute", 0.2) != (None, "timeout"): + failures.append("python timeout") + c2 = m.Client.connect(port) + if c2.try_send("bye", 1) != (None, "closed"): + failures.append("python closed") + sys.path.pop(0) + + +def run(libsh, libpy, port, failures): + check_shell(libsh, port, failures) + check_python(libpy, port, failures) + + +def main(): + srv = socket.socket() + srv.bind(("127.0.0.1", 0)) + srv.listen(16) + port = srv.getsockname()[1] + stop = threading.Event() + for _ in range(8): + threading.Thread(target=serve, args=(srv, stop), daemon=True).start() + good = [] + run(os.path.join(LIB, "protocol.sh"), LIB, port, good) + if good: + print("FAIL the clients:", *good, sep="\n ") + return 1 + print("ok: the shell and the python client frame by NUL, split replies, time out and close") + # sabotage: frame by newline, and the same checks must fail + with tempfile.TemporaryDirectory() as d: + sh = open(os.path.join(LIB, "protocol.sh")).read().replace("'%s\\0'", "'%s\\n'").replace("-d ''", "-d $'\\n'") + py = open(os.path.join(LIB, "plugin_hostd_client.py")).read().replace('b"\\0"', 'b"\\n"') + if sh == open(os.path.join(LIB, "protocol.sh")).read() or py == open(os.path.join(LIB, "plugin_hostd_client.py")).read(): + print("FAIL the sabotage changed nothing") + return 1 + open(os.path.join(d, "protocol.sh"), "w").write(sh) + open(os.path.join(d, "plugin_hostd_client.py"), "w").write(py) + bad_sh, bad_py = [], [] + check_shell(os.path.join(d, "protocol.sh"), port, bad_sh) + try: + check_python(d, port, bad_py) + except Exception as e: + bad_py.append(repr(e)) + if not bad_sh or not bad_py: + print("FAIL a client that frames by newline passed: shell %r, python %r" % (bad_sh, bad_py)) + return 1 + print("ok: a client that frames by newline fails the same checks") + stop.set() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/verbs_declared.py b/tests/verbs_declared.py index a9fa726..644452c 100644 --- a/tests/verbs_declared.py +++ b/tests/verbs_declared.py @@ -17,7 +17,9 @@ import json, os, re, socket, subprocess, sys, tempfile, time sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "lib")) import harness +import plugin_hostd_client import scan_fixture from harness import Daemon, Fail, check, wait_for @@ -327,6 +329,7 @@ def __init__(self): while True: try: self.sock = socket.create_connection(("127.0.0.1", self.port), timeout=10) + self.client = plugin_hostd_client.Client(self.sock) self.fb = socket.create_connection(("127.0.0.1", fb), timeout=10) break except OSError: @@ -335,14 +338,10 @@ def __init__(self): time.sleep(0.02) def send(self, msg): - self.sock.sendall(msg.encode() + b"\0") - buf = b"" - while not buf.endswith(b"\0"): - c = self.sock.recv(65536) - if not c: - raise Fail("the fake worker closed on " + msg) - buf += c - return buf[:-1].decode() + try: + return self.client.send(msg) + except ConnectionError: + raise Fail("the fake worker closed on " + msg) def close(self): self.proc.kill()