diff --git a/forgetest/forgetest/manifest.py b/forgetest/forgetest/manifest.py index fb2258e..56abbd5 100644 --- a/forgetest/forgetest/manifest.py +++ b/forgetest/forgetest/manifest.py @@ -181,9 +181,11 @@ HOST_TESTED = [ ("forgectrl", "src/paths.h", "forgectrl:tests/host_routes.sh", "forgectrl:.github/workflows/build.yml"), # The first run in the page's order: the four documents at their hashes, # the press step breathing teal, the press taken as the acceptance, the - # form steps through main.c's hooks (extensions.md stays with - # exthost.service, which covers it). + # form steps through main.c's hooks; and the on-demand Extensions + # advisory, accepted at its hash with the typed phrase. ("forgectrl", "src/advisories.*", "forgectrl:tests/host_setup.sh", "forgectrl:.github/workflows/build.yml"), + ("forgectrl", "docs/advisories/extensions.md", "forgectrl:tests/host_setup.sh", + "forgectrl:.github/workflows/build.yml"), ("forgectrl", "docs/advisories/safety-and-risk.md", "forgectrl:tests/host_setup.sh", "forgectrl:.github/workflows/build.yml"), ("forgectrl", "docs/advisories/licenses.md", "forgectrl:tests/host_setup.sh", "forgectrl:.github/workflows/build.yml"), @@ -207,6 +209,34 @@ HOST_TESTED = [ # store that holds only hashes, and the extension host's credential # (mode 0600, exactly the host's capabilities). ("forgectrl", "src/tokens.*", "forgectrl:tests/tokens_test.c", "forgectrl:.github/workflows/build.yml"), + # The logging tree's paths, the level and target validators, the + # configured levels. + ("forgectrl", "src/logs.*", "forgectrl:tests/logs_test.c", "forgectrl:.github/workflows/build.yml"), + # The extension host, each part to the test that drives it (the + # freeze, the supervisor, the machine client and the groups stay with + # exthost.armed-freeze, which covers them). + ("forgeext", "src/api.*", "forgeext:tests/api_test.c", "forgeext:.github/workflows/build.yml"), + ("forgeext", "src/caps.*", "forgeext:tests/caps_test.c", "forgeext:.github/workflows/build.yml"), + ("forgeext", "src/evfeed.*", "forgeext:tests/evfeed_test.c", "forgeext:.github/workflows/build.yml"), + ("forgeext", "src/holdkeep.*", "forgeext:tests/holdkeep_test.c", "forgeext:.github/workflows/build.yml"), + ("forgeext", "src/httpreq.*", "forgeext:tests/httpreq_test.c", "forgeext:.github/workflows/build.yml"), + ("forgeext", "src/manifest.*", "forgeext:tests/manifest_test.c", "forgeext:.github/workflows/build.yml"), + ("forgeext", "src/quota.*", "forgeext:tests/quota_test.c", "forgeext:.github/workflows/build.yml"), + ("forgeext", "src/sandbox.*", "forgeext:tests/sandbox_test.c", "forgeext:.github/workflows/build.yml"), + ("forgeext", "src/settings.*", "forgeext:tests/settings_test.c", "forgeext:.github/workflows/build.yml"), + ("forgeext", "src/state.*", "forgeext:tests/state_test.c", "forgeext:.github/workflows/build.yml"), + ("forgeext", "src/netrules.*", "forgeext:tests/netrules_test.py", "forgeext:.github/workflows/build.yml"), + # A package's page asking its own service (forgeext call). + ("forgeext", "src/call.*", "forgeext:tests/sdk_test.py", "forgeext:.github/workflows/build.yml"), + # Archives in and refused, and the signed index. + ("forgeext", "src/install.*", "forgeext:tests/install_test.py", "forgeext:.github/workflows/build.yml"), + ("forgeext", "src/pkg.*", "forgeext:tests/install_test.py", "forgeext:.github/workflows/build.yml"), + ("forgeext", "src/index.*", "forgeext:tests/install_test.py", "forgeext:.github/workflows/build.yml"), + # The daemon as built, end to end: its command line, its log, the + # killed host under the init script's wrapper. + ("forgeext", "src/main.c", "forgeext:tests/run_test.py", "forgeext:.github/workflows/build.yml"), + ("forgeext", "src/fflog.*", "forgeext:tests/run_test.py", "forgeext:.github/workflows/build.yml"), + ("forgeext", "CMakeLists.txt", "forgeext:tests/run_test.py", "forgeext:.github/workflows/build.yml"), ] # This repository's root, when the package runs from a checkout of it; a diff --git a/forgetest/forgetest/suite/exthost.py b/forgetest/forgetest/suite/exthost.py index 3f60888..292de2c 100644 --- a/forgetest/forgetest/suite/exthost.py +++ b/forgetest/forgetest/suite/exthost.py @@ -8,7 +8,6 @@ import json import os import re import signal -import stat import subprocess import sys import time @@ -488,8 +487,6 @@ FWUP = "/usr/bin/fwup" EXT_ROOT = "/data/forgefirm/ext" HOST_STATUS = "/run/forgefirm/ext/status.json" SAFE_FILE = "/run/forgefirm/ext-safe" -HOST_LOG = "/data/log/forgefirm/forgeext/forgeext.log" -API_DIR = "/run/forgefirm/ext/api" REF_ID = "org.forgetest.reference" REF_KEY = "forgetest-reference" REF_DEST = "192.0.2.1" # TEST-NET-1: declared so that port 443 is, and never dialed @@ -841,323 +838,6 @@ def _as_found(ctx, fc, prior, raw, dir_mode, found_tree): ctx.check(len(_host_pids()) == 1, "the extension host is not running at the end") -@test("exthost.service", title="A package's service runs confined under the extension host", - subsystem="exthost", kind="auto", hardware="takeover", est_min=5, - covers=[("forgeext", "**"), ("forgectrl", "src/main.c"), ("forgectrl", "src/logs.*"), - ("forgectrl", "docs/advisories/extensions.md")], - requires=["exthost.platform"], - description="The extension host is one running process (/usr/bin/forgeext run) and the status " - "file is its word, and with extensions off it runs nothing and says why. The test builds a reference " - "package on the board, signs it with a key it makes there, and adds that key as an owner " - "key: an install without the community consent is refused, with it the package is " - "installed at the community tier. It turns extensions on over the advisory. The service " - "then runs as its pool account with no_new_privs and a seccomp filter, in its own group " - "under /sys/fs/cgroup/ffx with the limits of a service (25 percent of the core, 48 MiB, 32 " - "processes), with its chain in the rule table. From the inside it reads /etc and its " - "package, writes its data directory and nothing else, cannot read the settings file, the " - "setup record, or the pulse device, is refused by the machine on loopback and on its LAN " - "address even on the port it declared, cannot connect on a port it did not declare, and " - "cannot open a netlink socket. Its one way to the machine is its API socket (root's and its " - "account's, 0660, refused to another pool account): GET /v0/self names it and what it may use, " - "GET /v0/machine/mode is forgectrl's answer relayed, POST /v0/camera gives it a JPEG from the " - "lid camera it holds (with the camera's lamp at 0 and at 1023 two different pictures, the lit " - "one the larger) and refuses the head camera and a lamp past its range, a hold it was not granted is 403, a path " - "the API does not have 404, a path with .. 400; its output is in the forgeext log under its id. Safe mode " - "stops it and its end starts it again. A host killed outright takes its services with it " - "at once (the init wrapper), comes back, and starts the service again. ext_enabled=0 " - "stops it and leaves no group and no chain. The package, the key, the setting, and the " - "setup record are put back as found, the record under a forgectrl restart. The layer " - "content (the recipe, the init script's install, the image list) is in the platform " - "identity of every fingerprint.") -def service(ctx): - import shutil - import tempfile - fc = ctx.forgectrl - ev = ctx.evidence - ctx.check(os.path.isfile(FORGEEXT) and os.access("/etc/init.d/forgeext", os.X_OK), - "the image has no forgeext, or no init script for it") - hosts = _host_pids() - ev["host_pids"] = hosts - ctx.check(len(hosts) == 1, "the extension host is not one running process: %s", hosts) - ctx.check(_until(ctx, lambda: _host_status().get("pid") == hosts[0], 10), - "the status file %s is not the running host's: %s", HOST_STATUS, _host_status().get("pid")) - ctx.check(fc.wait_idle(timeout=30, abort=ctx.aborted), "machine not idle: settings are locked") - prior = fc.settings().get("ext_enabled") or "" - raw = read_file(record_path()) - ctx.check(raw, "no setup record at %s", record_path()) - ctx.check(not os.path.exists(SAFE_FILE), "%s exists: the machine is in safe mode", SAFE_FILE) - ctx.check(REF_ID not in [x.get("id") for x in _forgeext("list").get("packages", [])], - "%s is already installed", REF_ID) - found_tree = _tree(EXT_ROOT) - data_dir = os.path.dirname(EXT_ROOT) - dir_mode = os.stat(data_dir).st_mode & 0o7777 - ev["data_dir_mode_found"] = "%04o" % dir_mode - if prior != "1": - st = _host_status() - ev["off"] = {k: st.get(k) for k in ("enabled", "off_reason")} - ctx.check(st.get("enabled") is False and "ext_enabled" in (st.get("off_reason") or "") - and not [x for x in st.get("services", []) if x.get("state") == "running"], - "with extensions off the host does not say so: %s", ev["off"]) - - st, body, hdrs = request(fc.base, "GET", "/advisories/extensions", headers={"Host": fc.host_header()}) - etag = hdrs.get("etag") - ctx.check(st == 200 and etag, "GET /advisories/extensions -> %s", st) - work = tempfile.mkdtemp(prefix="forgetest-ffx.") - owner_key = os.path.join(EXT_ROOT, "keys", REF_KEY + ".pub") - uid = None - - def cg(name): - return _read("%s/%s/%s" % (POOL_GROUP, REF_ID, name)).strip() - - def chains(): - return subprocess.run([NFT, "list", "table", "inet", "ffx"], capture_output=True, text=True, timeout=30).stdout - - def running(other_than=0): - # The status file outlives a killed host: a pid that is the old one is no news. - x = _svc(REF_ID) - return x if x.get("state") == "running" and x.get("pid") and x["pid"] != other_than and os.path.exists("/proc/%d" % x["pid"]) else None - - try: - lan = lan_ip() - archive, pub = _pack_reference(work, lan) - r = _forgeext("inspect", archive) - ev["inspect_without_the_key"] = r.get("tier") - ctx.check(r.get("ok") and r.get("tier") == "unverified", "before its key is the owner's the package reads %s", r) - shutil.copy(pub, owner_key) - os.chmod(owner_key, 0o644) - r = _forgeext("inspect", archive) - ctx.check(r.get("ok") and r.get("tier") == "community", "with the owner's key the package reads %s", r) - r = _forgeext("install", archive) - ev["install_without_consent"] = r.get("error") - ctx.log("install without the consent -> %s", r.get("error")) - ctx.check(r.get("ok") is False, "a community package was installed without the consent") - r = _forgeext("install", archive, "--consent-community") - ctx.check(r.get("ok") is True, "the install -> %s", r.get("error")) - ctx.check(_forgeext("check", REF_ID).get("ok") is True, "the installed tree fails its integrity check") - - st, reply = fc.post("/settings", data={"ext_enabled": "1", "advisory": etag, "phrase": SAFETY_PHRASE}) - ctx.check(st == 200, "ext_enabled=1 over the advisory -> %s %r", st, reply) - x = _until(ctx, running, 90, poll=0.5) - ev["status_at_start"] = _host_status() - ctx.check(x, "the service is not running: %s", {k: ev["status_at_start"].get(k) for k in ("enabled", "off_reason", "not_ready")} - if not _svc(REF_ID) else _svc(REF_ID)) - pid = x["pid"] - uid = POOL_FIRST + int(x["account"][3:]) - proc = _read("/proc/%d/status" % pid) - ev["service"] = {"pid": pid, "account": x["account"], "cgroup": _read("/proc/%d/cgroup" % pid).strip(), - "cpu.max": cg("cpu.max"), "memory.max": cg("memory.max"), "pids.max": cg("pids.max"), - "memory.current": cg("memory.current")} - ctx.log("the service: %s", ev["service"]) - ctx.check(("Uid:\t%d\t%d\t%d\t%d" % ((uid,) * 4)) in proc and ("Gid:\t%d\t%d\t%d\t%d" % ((uid,) * 4)) in proc, - "the service does not run as %s alone", x["account"]) - ctx.check("NoNewPrivs:\t1" in proc and "Seccomp:\t2" in proc, "no no_new_privs or no seccomp filter on the service") - ctx.check(ev["service"]["cgroup"] == "0::/ffx/" + REF_ID, "the service's group is %s", ev["service"]["cgroup"]) - ctx.check(cg("cpu.max").split() == ["25000", "100000"] and cg("memory.max") == str(48 << 20) and cg("pids.max") == "32", - "the group's limits are not a service's: %s", ev["service"]) - table = chains() - ctx.check(("chain u%d " % uid) in table and REF_DEST in table, "the service's chain is not in the rule table") - - report_path = os.path.join(EXT_ROOT, "data", REF_ID, "report.json") - ctx.check(_until(ctx, lambda: os.path.isfile(report_path), 60, poll=0.5), "the service left no report") - rep = json.loads(_read(report_path)) - ev["from_the_inside"] = rep - ctx.log("from the inside: %s", rep) - want = {"uid": uid, "gid": uid, "groups": [], "read_etc": "ok", "read_pkg": "ok", "write_data": "ok", - "read_settings": "EACCES", "read_setup": "EACCES", "write_pkg": "EACCES", "write_tmp": "EACCES", - "pulse_device": "EACCES", "loopback_declared_port": "ECONNREFUSED", - "loopback_undeclared_port": "EACCES", "unix_socket": "ok", "netlink_socket": "EPERM"} - if lan: - want["lan_declared_port"] = "ECONNREFUSED" - for k, v in want.items(): - ctx.check(rep.get(k) == v, "from the inside, %s is %r, expected %r", k, rep.get(k), v) - # LC_CTYPE is the interpreter's own doing (it coerces the C locale at its start) - fixed = {"PATH", "LANG", "HOME", "TMPDIR", "FFX_ID", "FFX_PKG", "FFX_DATA", "FFX_API", "PYTHONDONTWRITEBYTECODE", - "PYTHONUNBUFFERED"} - ctx.check(rep.get("cwd") == os.path.join(EXT_ROOT, "data", REF_ID) and set(rep.get("env") or []) - {"LC_CTYPE"} == fixed, - "its working directory or its environment is not the fixed one: %s %s", rep.get("cwd"), rep.get("env")) - # its one way to the machine: the API socket, and the broker behind it - me = rep.get("api_self") or [0, {}] - ev["api"] = {k: rep.get(k) for k in ("api_self", "api_mode", "api_hold", "api_nowhere", "api_traversal")} - ctx.check(me[0] == 200 and me[1].get("id") == REF_ID and me[1].get("version") == "1.0.0" - and sorted(me[1].get("capabilities") or []) == sorted(["net.outbound:%s:443" % REF_DEST, "storage:1", - "machine.read", "settings.own", - "camera.lid"]), - "GET /v0/self from the inside: %s", me) - - # a camera: the one it was granted, and not the one it was not - cam = rep.get("api_camera") or [0, "", False, False] - ev["api_camera"] = {"status": cam[0], "bytes": cam[1] if cam[0] == 200 else None, - "jpeg": cam[2], "image_type": cam[3], - "why": cam[1] if cam[0] != 200 else None} - ctx.log("the lid camera, from inside the sandbox: %s", ev["api_camera"]) - ctx.check(cam[0] == 200 and cam[2] and cam[3] and isinstance(cam[1], int) and cam[1] > 4096, - "the granted camera did not come back as a JPEG: %s", ev["api_camera"]) - head_ = rep.get("api_camera_head") or [0, ""] - ev["api_camera_head"] = head_[:2] - ctx.check(head_[0] == 403, "the head camera, which it was not granted -> %s", head_[:2]) - badcam = rep.get("api_camera_bad") or [0, ""] - ev["api_camera_bad"] = badcam[:2] - ctx.check(badcam[0] == 400, "a camera there is none of -> %s", badcam[:2]) - # The camera's own lamp for one frame: the machine lights it for - # the shot and puts its level back, so a frame with the lamp off - # and one with it full are two different pictures, the lit one the - # larger, and a level past the range is refused before the machine - # is asked. - dark = rep.get("api_camera_dark") or [0, "", False, False] - lit = rep.get("api_camera_lit") or [0, "", False, False] - ev["api_camera_lamp"] = {"dark": dark[:3], "lit": lit[:3]} - ctx.log("the lid camera with its lamp at 0 and at 1023: %s", ev["api_camera_lamp"]) - ctx.check(dark[0] == 200 and dark[2] and lit[0] == 200 and lit[2], - "a frame with the lamp named did not come back as a JPEG: %s", ev["api_camera_lamp"]) - ctx.check(isinstance(lit[1], int) and isinstance(dark[1], int) and lit[1] > dark[1], - "the lamp did not reach the camera: %d bytes at 1023, %d at 0", lit[1] or 0, dark[1] or 0) - lampbad = rep.get("api_camera_lamp_bad") or [0, ""] - ev["api_camera_lamp_bad"] = lampbad[:2] - ctx.check(lampbad[0] == 400 and "lamp" in str(lampbad[1]), "a lamp past its range -> %s", lampbad[:2]) - - # A package's capture yields to somebody watching a camera: it is - # never the person standing at the machine. - import urllib.request - shot_file = os.path.join(EXT_ROOT, "data", REF_ID, "shot") - if os.path.exists(shot_file): - os.remove(shot_file) - held = urllib.request.urlopen( - urllib.request.Request(fc.base + "/cam/stream", headers={"Host": fc.host_header()}), timeout=15) - try: - held.read(8192) - watching = _until(ctx, lambda: (fc.get("/cam/status")[1] or {}).get("clients"), 15, poll=0.5) - ctx.check(watching, "no viewer was counted while the stream was held") - _write(os.path.join(EXT_ROOT, "data", REF_ID, "shoot"), "now") - yielded = _until(ctx, lambda: json.loads(_read(shot_file)) if os.path.exists(shot_file) else None, - 30, poll=0.5) - ev["camera_while_watched"] = yielded - ctx.log("the package's capture while a viewer watches: %s", yielded) - ctx.check(yielded and yielded[0] == 409, - "a package's capture did not yield to a viewer: %s", yielded) - ctx.check(yielded and "watching" in str(yielded[1]), - "it yielded without the machine's own words: %s", yielded) - finally: - held.close() - os.remove(shot_file) - _until(ctx, lambda: not (fc.get("/cam/status")[1] or {}).get("clients"), 15, poll=0.5) - _write(os.path.join(EXT_ROOT, "data", REF_ID, "shoot"), "now") - again = _until(ctx, lambda: json.loads(_read(shot_file)) if os.path.exists(shot_file) else None, - 40, poll=0.5) - ev["camera_after_watching"] = [again[0], again[1]] if again else None - ctx.check(again and again[0] == 200 and again[2], - "the package's capture did not come back once the viewer stopped: %s", again) - - # its own settings: read, set, and what the schema will not take - got = rep.get("api_settings") or [0, {}] - ev["api_settings"] = got - ctx.check(got[0] == 200 and (got[1] or {}).get("settings", {}).get("threshold") == 40 - and (got[1] or {}).get("settings", {}).get("when") == "end" - and len((got[1] or {}).get("schema") or []) == 3, - "GET /v0/settings before anything is set, with its schema: %s", got) - set_ = rep.get("api_settings_set") or [0, {}] - ev["api_settings_set"] = set_ - ctx.check(set_[0] == 200 and (set_[1] or {}).get("settings", {}).get("threshold") == 70 - and (set_[1] or {}).get("settings", {}).get("note") == "set from inside" - and (set_[1] or {}).get("settings", {}).get("when") == "end", - "POST /v0/settings sets what it names and leaves the rest: %s", set_) - for key, why in (("api_settings_undeclared", "a setting the package does not declare"), - ("api_settings_out_of_bounds", "a value outside its bounds")): - bad_ = rep.get(key) or [0, {}] - ev[key] = bad_ - ctx.check(bad_[0] == 400 and (bad_[1] or {}).get("error"), "%s -> %s", why, bad_) - store = "%s/settings/%s.json" % (EXT_ROOT, REF_ID) - ev["settings_store"] = {"mode": "%04o" % (os.stat(store).st_mode & 0o7777) if os.path.exists(store) else None} - ctx.check(os.path.exists(store) and os.stat(store).st_mode & 0o7777 == 0o600, - "the settings store is not root's alone: %s", ev["settings_store"]) - ctx.check(os.path.exists(store) and json.loads(_read(store)).get("threshold") == 70, - "the store does not hold what the package set") - mode_ = rep.get("api_mode") or [0, {}] - ctx.check(mode_[0] == 200 and mode_[1].get("mode") in ("grbl", "cloud") and "controller" in mode_[1], - "GET /v0/machine/mode from the inside is not forgectrl's answer: %s", mode_) - ctx.check((rep.get("api_hold") or [0])[0] == 403 and (rep.get("api_nowhere") or [0])[0] == 404 - and (rep.get("api_traversal") or [0])[0] == 400, - "a hold it was not granted, a path the API does not have, a path with ..: %s %s %s", - rep.get("api_hold"), rep.get("api_nowhere"), rep.get("api_traversal")) - # the storage quota: the host measures what it holds and sets it aside - big = os.path.join(EXT_ROOT, "data", REF_ID, "toobig") - with open(big, "wb") as f: - f.write(b"x" * (2 << 20)) - ctx.log("wrote 2 MiB into its data directory (it declares storage:1)") - - def quarantined(): - x = _svc(REF_ID) - return x if x.get("state") == "quarantined" else None - - q = _until(ctx, quarantined, 90, poll=1.0) - ev["quota"] = {"state": (q or _svc(REF_ID)).get("state"), "reason": (q or _svc(REF_ID)).get("reason")} - ctx.log("over its quota: %s", ev["quota"]) - ctx.check(q, "a service 2 MiB over its 1 MiB quota was not set aside: %s", _svc(REF_ID)) - ctx.check("may hold" in ((q or {}).get("reason") or ""), - "it was set aside without the quota's reason: %s", (q or {}).get("reason")) - ctx.check(not os.path.exists("/proc/%d" % pid), "it is still running after being set aside") - # The operator's way out: clear the data, enable it again. - os.remove(big) - r = _forgeext("enable", REF_ID) - ctx.check(r.get("ok") is True, "enable after the quota -> %s", r.get("error")) - back = _until(ctx, lambda: _svc(REF_ID) if _svc(REF_ID).get("state") == "running" else None, 90, poll=0.5) - ctx.check(back, "it did not come back once its data was cleared and it was enabled: %s", _svc(REF_ID)) - pid = (back or {}).get("pid") or pid - - sock = "%s/%s.sock" % (API_DIR, REF_ID) - st_ = os.stat(sock) - ctx.check(stat.S_ISSOCK(st_.st_mode) and stat.S_IMODE(st_.st_mode) == 0o660 and (st_.st_uid, st_.st_gid) == (0, uid), - "its API socket is not root's and its account's at 0660: %o %d:%d", stat.S_IMODE(st_.st_mode), st_.st_uid, st_.st_gid) - other = POOL_FIRST + POOL_SIZE - 1 if uid != POOL_FIRST + POOL_SIZE - 1 else POOL_FIRST - rc, lines, errtext = _probe("-", other, "unix", sock) - ev["another_account_at_its_socket"] = lines - ctx.check(lines and lines[0].get("result") == "eacces", "another pool account at its socket: %s %s", lines, errtext) - ctx.check(_until(ctx, lambda: ("ext %s: reference service up" % REF_ID) in _read(HOST_LOG), 20, poll=1), - "the service's output is not in %s under its id", HOST_LOG) - - _write(SAFE_FILE, "") - ctx.check(_until(ctx, lambda: not running() and "safe mode" in (_host_status().get("off_reason") or ""), 15), - "safe mode did not stop the service: %s", _host_status()) - ctx.check(not os.path.exists("/proc/%d" % pid) and not os.path.isdir("%s/%s" % (POOL_GROUP, REF_ID)), - "safe mode left the process or its group") - os.remove(SAFE_FILE) - x = _until(ctx, lambda: running(pid), 60, poll=0.5) - ctx.check(x, "out of safe mode the service did not start again: %s", _svc(REF_ID)) - pid = x["pid"] - ctx.log("safe mode stopped it and its end started it again (pid %d)", pid) - - host = _host_pids() - ctx.check(len(host) == 1, "the extension host is not one process: %s", host) - # Only once the service is in its quiet loop: a service still on its way up ends by - # itself when its first line meets the dead host's pipe, and that would prove nothing. - beat_path = os.path.join(EXT_ROOT, "data", REF_ID, "beat") - seen = set() - ctx.check(_until(ctx, lambda: seen.add(_read(beat_path)) or len(seen) >= 3, 30, poll=0.2), - "the service's heartbeat does not advance") - t0 = time.time() - os.kill(host[0], signal.SIGKILL) - ctx.check(_until(ctx, lambda: not os.path.exists("/proc/%d" % pid), 2, poll=0.05), - "a killed host left its service running for 2 s: nobody would freeze it in an armed window") - ev["service_outlived_a_killed_host_s"] = round(time.time() - t0, 2) - x = _until(ctx, lambda: running(pid) if _host_status().get("pid") in _host_pids() else None, 60, poll=0.5) - ev["host_back_s"] = round(time.time() - t0, 1) - ctx.check(x, "the host did not come back and start the service again: %s", _host_status()) - pid = x["pid"] - ctx.log("a killed host: its service gone in %.2f s, the host back and the service running after %.1f s", - ev["service_outlived_a_killed_host_s"], ev["host_back_s"]) - - st, reply = fc.post("/settings", data={"ext_enabled": "0"}) - ctx.check(st == 200, "ext_enabled=0 -> %s %r", st, reply) - ctx.check(_until(ctx, lambda: not running() and not os.path.exists("/proc/%d" % pid), 15), - "ext_enabled=0 did not stop the service: %s", _svc(REF_ID)) - ctx.check(not os.path.isdir("%s/%s" % (POOL_GROUP, REF_ID)) and ("chain u%d " % uid) not in chains(), - "ext_enabled=0 left the service's group or its chain") - ctx.check("ext_enabled" in (_host_status().get("off_reason") or ""), "the host does not say why nothing runs: %s", - _host_status().get("off_reason")) - finally: - _put_back(ctx, fc, work, prior, etag, raw, dir_mode) - _as_found(ctx, fc, prior, raw, dir_mode, found_tree) - - # ------------------------------------------------- the armed window def _frozen(id_): @@ -1170,7 +850,7 @@ def _frozen(id_): covers=[("forgeext", "src/super.*"), ("forgeext", "src/run.*"), ("forgeext", "src/machine.*"), ("forgeext", "src/cgroup.*"), ("forgectrl", "src/cool.*"), ("forgectrl", "src/cam.*"), ("forgectrl", "src/main.c")], - requires=["exthost.service"], actions=["button"], + requires=["exthost.platform"], actions=["button"], steps=[OFFLINE_STEP, "Bed clear (the job is dark: a 30 s square at S0, nothing fires). Press the button " "when it lights."], @@ -1186,8 +866,8 @@ def _frozen(id_): "service is the same process throughout. A background capture is refused in words " "while the window is open and served again once it closes, which is the other half " "of keeping a package off the step stream: a capture costs kernel-side work that a " - "thread priority does not cover. Everything is put back as exthost.service puts it " - "back.") + "thread priority does not cover. The package, the key, the setting and the setup " + "record are put back as found, the record under a forgectrl restart.") def armed_freeze(ctx): import tempfile import threading diff --git a/forgetest/forgetest/suite/forgectrl.py b/forgetest/forgetest/suite/forgectrl.py index b0d91c5..87574e8 100644 --- a/forgetest/forgetest/suite/forgectrl.py +++ b/forgetest/forgetest/suite/forgectrl.py @@ -4,9 +4,7 @@ # SPDX-License-Identifier: MIT """forgectrl.* - the machine-services daemon's API, access control, and panel.""" -import json import socket -import time from ..catalog import test @@ -15,7 +13,7 @@ from ..catalog import test # write with the token), so both are the guard's domain. _COVERS_AUTH = [("forgectrl", "src/auth.*"), ("forgectrl", "src/peer.*"), ("forgectrl", "src/main.c"), ("forgectrl", "src/session.*"), ("forgectrl", "src/users.*"), - ("forgectrl", "src/ui/login.js"), ("forgectrl", "src/ui/wizard.js")] + ("forgectrl", "src/ui/login.*"), ("forgectrl", "src/ui/wizard.js")] def lan_ip(): @@ -210,104 +208,3 @@ def auth(ctx): if st == 302: ctx.check(loc == "/login?next=/setup%3Fstep%3Dlaser.focus", "the login redirect does not carry the path: %r", loc) - - -@test("forgectrl.settings-bounds", title="Settings validation and restore", subsystem="forgectrl", - kind="auto", est_min=1, - covers=[("forgectrl", "src/settings.*"), ("forgectrl", "src/main.c"), ("forgectrl", "src/cam.c")], - description="An over-length value and an out-of-range value are refused (400) and leave the " - "settings byte-identical; an in-range value is accepted (200). The lid lamp " - "idles at lid_lamp_idle (unset = 236), an out-of-range level is refused, a new " - "level applies to the lamp at once, and clearing it returns the default.") -def settings_bounds(ctx): - fc = ctx.forgectrl - ev = ctx.evidence - before = fc.settings() - ev["keys"] = len(before) - - st, body = fc.post("/settings", data={"gf_serial": "X" * 300}) - ev["overlong"] = st - ctx.log("POST /settings gf_serial=<300 chars> -> %s %s", st, body if isinstance(body, dict) else "") - ctx.check(st == 400, "over-length value -> %s, expected 400", st) - - st, body = fc.post("/settings", data={"laser_disarm_s": "99999"}) - ev["out_of_range"] = st - ctx.log("POST /settings laser_disarm_s=99999 -> %s", st) - ctx.check(st == 400, "out-of-range value -> %s, expected 400", st) - - # The cloud download guard is bytes, so its range is far wider than the - # other numeric keys: check the far end is still a wall. - st, body = fc.post("/settings", data={"pulse_reject_threshold_bytes": "2000000000"}) - ev["pulse_bytes_out_of_range"] = st - ctx.log("POST /settings pulse_reject_threshold_bytes=2000000000 -> %s", st) - ctx.check(st == 400, "out-of-range byte limit -> %s, expected 400", st) - - st, body = fc.post("/settings", data={"no_such_key_forgetest": "1"}) - ev["unknown_key"] = st - ctx.log("POST /settings no_such_key_forgetest=1 -> %s", st) - ctx.check(st in (400, 404), "unknown key -> %s, expected 400", st) - - after = fc.settings() - ctx.check(json.dumps(after, sort_keys=True) == json.dumps(before, sort_keys=True), - "settings changed after refused writes") - ctx.log("settings unchanged after the refused writes") - - # an accepted in-range write: rewrite a present key with its own value - key = None - for k in ("ui_units", "laser_disarm_s", "cool_flow_rise", "rail_settle_s"): - v = before.get(k) - if isinstance(v, str) and v != "": - key = k - break - if key is None: - key, val = "ui_units", "metric" - ctx.log("no settable key is present; writing %s=%s (recorded in evidence)", key, val) - else: - val = before[key] - st, body = fc.post("/settings", data={key: val}) - ev["accepted"] = {"key": key, "value": val, "status": st} - ctx.log("POST /settings %s=%s -> %s", key, val, st) - ctx.check(st == 200, "in-range write -> %s, expected 200", st) - final = fc.settings() - others_before = {k: v for k, v in before.items() if k != key} - others_after = {k: v for k, v in final.items() if k != key} - ctx.check(others_before == others_after, "other settings changed by the write") - ctx.check(final.get(key) == val, "%s reads back %r, wrote %r", key, final.get(key), val) - - # the lid lamp's idle level: resting at the setting, bounded, applied live - lamp_was = (before.get("lid_lamp_idle") or "").strip() - want = lamp_was or "236" - got = ctx.sysfs("pic/lid_led") - ev["lid_lamp"] = {"setting": lamp_was, "resting": got} - ctx.log("lid lamp: setting %r, pic/lid_led=%s (expected %s)", lamp_was, got, want) - ctx.check(got == want, "lid lamp rests at %s, lid_lamp_idle is %s", got, want) - for bad in ("256", "-1", "bright"): - st, body = fc.post("/settings", data={"lid_lamp_idle": bad}) - ctx.check(st == 400, "lid_lamp_idle=%s -> %s, expected 400", bad, st) - ctx.log("lid_lamp_idle 256 / -1 / bright refused") - try_level = "100" if want != "100" else "120" - st, body = fc.post("/settings", data={"lid_lamp_idle": try_level}) - ctx.check(st == 200, "lid_lamp_idle=%s -> %s, expected 200", try_level, st) - applied = None - t0 = time.time() - while time.time() - t0 < 5: - applied = ctx.sysfs("pic/lid_led") - if applied == try_level: - break - ctx.sleep(0.2) - ctx.log("lid_lamp_idle=%s -> pic/lid_led=%s after %.1f s", try_level, applied, time.time() - t0) - # an empty value clears the key: the query-string form carries it - st, body = (fc.post("/settings", params={"lid_lamp_idle": ""}) if not lamp_was - else fc.post("/settings", data={"lid_lamp_idle": lamp_was})) - ctx.check(st == 200, "restoring lid_lamp_idle=%r -> %s", lamp_was, st) - t0 = time.time() - back = None - while time.time() - t0 < 5: - back = ctx.sysfs("pic/lid_led") - if back == want: - break - ctx.sleep(0.2) - ev["lid_lamp"].update({"applied": applied, "restored": back}) - ctx.check(applied == try_level, "lamp did not follow lid_lamp_idle=%s (reads %s)", try_level, applied) - ctx.check(back == want, "lamp did not return to %s after the restore (reads %s)", want, back) - ctx.log("lid lamp follows the setting live and returns to %s", want) diff --git a/forgetest/forgetest/suite/image.py b/forgetest/forgetest/suite/image.py index 1bf7462..61d1986 100644 --- a/forgetest/forgetest/suite/image.py +++ b/forgetest/forgetest/suite/image.py @@ -4,11 +4,9 @@ # SPDX-License-Identifier: MIT """image.* - the post-flash health of the running image (always-required).""" -import glob import gzip import mmap import os -import shutil import struct import re import stat @@ -123,15 +121,15 @@ def fds_of(pid): "the daemon ownership, the board temperatures /status reports (the chassis and " "the SoC die in a plausible range, the supply as a 10-bit count, the CPU " "unthrottled), " - "the init ordering, the extension host as one process that starts after forgectrl and " - "stops before it on a machine with no package installed and nothing running under a " - "pool account, the data directory open for search on a machine with an account, the file modes the release depends on, and the mounts: the " + "the extension host as one process on a machine with no package installed and " + "nothing running under a pool account, no userspace watchdog running, " + "the data directory open for search on a machine with an account, the file modes the release depends on, and the mounts: the " "rootfs read-only, /data writable, the account files and the banner rendered " - "into tmpfs, the sshd host keys on /data, the factory slots on the dev image only. " + "into tmpfs, the sshd host keys on /data, the dev image's factory slots read-only. " "And the boot does not wait on the network: ifup has returned for wlan0 (it is " "in the state file and no ifup is running) and the console login is up; each " "DHCP client runs with -b and has left ifup (its parent is pid 1); no DHCPv6 " - "client is running or on the image, nor its hook script; and IPv6 is the " + "client is running; and IPv6 is the " "kernel's own: enabled on wlan0, router advertisements accepted, a link-local " "address up (a global address is logged, not checked).") def image_health(ctx): @@ -284,17 +282,8 @@ def image_health(ctx): ctx.check(temps.get("soc_throttle") == 0, "the CPU is throttled (or its cooling device is missing) at idle: %s", temps) - # 5. init ordering: controllers stop before the daemon - k = sorted(os.path.basename(p) for p in glob.glob("/etc/rc6.d/K*")) - ev["rc6_kill"] = k - kg = [x for x in k if "grblhal" in x] - kf = [x for x in k if x.endswith("forgectrl")] - ctx.log("rc6.d: %s", " ".join(k)) - ctx.check(kg and kf, "rc6.d lacks the grblhal/forgectrl kill links") - ctx.check(kg[0] < kf[0], "controller kill link %s must sort before forgectrl's %s", kg[0], kf[0]) - - # 5b. the extension host: one process, up after forgectrl and down - # before it, and a campaign's machine is extension-free + # 5. the extension host: one process, and a campaign's machine is + # extension-free (its init order is the rootfs lint's) host = [] for pid in hw.pidof("forgeext"): argv = _read("/proc/%d/cmdline" % pid, "").split("\0") @@ -302,13 +291,6 @@ def image_health(ctx): host.append(pid) ev["extension_host_pids"] = host ctx.check(len(host) == 1, "the extension host is not one running process: %s", host) - s5 = sorted(os.path.basename(x) for x in glob.glob("/etc/rc5.d/S*")) - sx = [x for x in s5 if x.endswith("forgeext")] - sf = [x for x in s5 if x.endswith("forgectrl")] - kx = [x for x in k if x.endswith("forgeext")] - ev["forgeext_links"] = {"start": sx, "kill": kx} - ctx.check(sx and sf and sf[0] < sx[0], "the extension host must start after forgectrl: %s, %s", sf, sx) - ctx.check(kx and kx[0] < kf[0], "the extension host must stop before forgectrl: %s, %s", kx, kf) ev["extension_packages"] = hw.ext_packages() ev["pool_processes"] = len(hw.pool_pids()) ctx.log("extension host pid %s, packages %s, pool processes %d", host, ev["extension_packages"], ev["pool_processes"]) @@ -316,12 +298,9 @@ def image_health(ctx): ev["extension_packages"]) ctx.check(not ev["pool_processes"], "%d process(es) run under extension pool accounts", ev["pool_processes"]) - # 6. logging lever present, no userspace watchdog daemon - logging = [n for n in ("forgefirm-logging", "forgefirm-logrotate") if os.path.exists("/etc/init.d/" + n)] - ev["logging_init"] = logging - ctx.check(logging, "no ForgeFIRM logging/logrotate init script") - ctx.check(not os.path.exists("/etc/init.d/watchdog"), "a userspace watchdog init script is present") - # kernel threads (the imx2_wdt kthread is expected) have an empty cmdline + # 6. no userspace watchdog daemon running (the logging init and the + # absent watchdog init script are the rootfs lint's); kernel threads + # (the imx2_wdt kthread is expected) have an empty cmdline user_wd = [p for p in hw.pidof("watchdog") if _read("/proc/%d/cmdline" % p, "")] ev["userspace_watchdog_pids"] = user_wd ctx.check(not user_wd, "a userspace watchdog daemon is running: %s", user_wd) @@ -351,8 +330,9 @@ def image_health(ctx): # 8. the mounts: the rootfs read-only, /data the writable partition, # the state a read-only rootfs hands off (the read-only-rootfs image # feature, forgefirm-users, forgefirm-hostname, forgefirm-banner, the - # sshd host keys). The - # dev image alone mounts the factory slots under /factory. + # sshd host keys). The dev image's factory slots under /factory are + # read-only; rcS's ROOTFS_READ_ONLY and the release fstab with no + # factory slot are the rootfs lint's. mounts = {} for line in (_read("/proc/mounts", "") or "").splitlines(): parts = line.split() @@ -370,8 +350,6 @@ def image_health(ctx): ctx.check("rw" in mount_opts("/data"), "/data is not mounted read-write: %s", mounts.get("/data")) ctx.check("rw" in mount_opts("/var/lib"), "/var/lib is not a writable copy (read-only-rootfs-hook.sh): %s", mounts.get("/var/lib")) - rcs = _read("/etc/default/rcS", "") or "" - ctx.check("ROOTFS_READ_ONLY=yes" in rcs.splitlines(), "/etc/default/rcS lacks ROOTFS_READ_ONLY=yes") factory = sorted(p for p in mounts if p.startswith("/factory/")) ev["factory_mounts"] = factory dev_image = os.path.exists("/etc/forgefirm-dev") @@ -382,8 +360,6 @@ def image_health(ctx): if os.path.exists("/dev/mmcblk2p%d" % n): p = "/factory/img%d" % n ctx.check("ro" in mount_opts(p), "%s is not mounted read-only on the dev image: %s", p, mounts.get(p)) - else: - ctx.check(not factory, "a release image mounts the factory slots: %s", factory) if os.path.isfile("/data/forgefirm/users"): names = [l.split(":")[0] for l in (_read("/data/forgefirm/users", "") or "").splitlines() if l.strip() and not l.startswith("#")] @@ -428,14 +404,10 @@ def image_health(ctx): for pid, ppid, _comm, cmd in v4: ctx.check("-b" in cmd.split(), "the DHCP client runs without -b: %s", cmd) ctx.check(ppid == 1, "the DHCP client (pid %d) is still a child of pid %d", pid, ppid) - # no DHCPv6 client: not running, not on the image + # no DHCPv6 client running (none on the image is the rootfs lint's) alive = [p[3] for p in procs if p[2] in DHCP6_CLIENTS] ev["dhcp6_running"] = alive ctx.check(not alive, "a DHCPv6 client is running: %s", alive) - for name in DHCP6_CLIENTS: - ctx.check(shutil.which(name) is None, "%s is on the image", name) - ctx.check(not os.path.exists("/usr/share/udhcpc/default6.script"), - "the DHCPv6 hook script is on the image") # IPv6 is the kernel's own. A global address is evidence only: a # network whose router advertisement offers no SLAAC prefix gives none. conf = "/proc/sys/net/ipv6/conf/wlan0/" diff --git a/forgetest/forgetest/suite/motion.py b/forgetest/forgetest/suite/motion.py index 6240345..7c44ea6 100644 --- a/forgetest/forgetest/suite/motion.py +++ b/forgetest/forgetest/suite/motion.py @@ -559,78 +559,6 @@ def _wait_controller(ctx, fc, states, timeout, motion=None): return m if isinstance(m, dict) else {} -# ---------------------------------------------------------------- cancel / abort - -@test("motion.cancel-abort", title="Jog cancel and controlled abort recover cleanly", subsystem="motion", - kind="auto", mode="grbl", est_min=2, - covers=_MOTION_COVERS, requires=["motion.pacing"], - steps=["Bed clear; the head needs 40 mm of free +X travel."], - description="A jog-cancel (0x85) stops a jog short of its target and returns to Idle with " - "position preserved; a ^X abort mid-move (what the sender's Stop sends) " - "decelerates under control into Alarm with machine position retained, leaves the " - "head where it stopped - no return-to-start, that is the lid policy's move alone - " - "and $X recovers to Idle with a subsequent jog running (no driver wedge: the rail " - "never cycled).") -def cancel_abort(ctx): - ev = ctx.evidence - with ctx.grbl() as g: - clean_slate(ctx, g) - start = g.status_report()["MPos"] - # jog cancel - g.command("$J=G91X40F2400") - ctx.sleep(0.4) - g.realtime(0x85) - st = wait_state(ctx, g, "Idle", 5) - ctx.check(st is not None, "not Idle within 5 s of the jog cancel") - p1 = st["MPos"] - moved1 = p1[0] - start[0] - ev["cancel_moved_mm"] = round(moved1, 3) - ctx.log("jog cancel: moved %.3f mm of 40 (state %s)", moved1, st["state"]) - ctx.check(0.5 < moved1 < 39.0, "jog cancel did not stop short of the target (%.3f mm)", moved1) - # ^X abort mid-move - g.command("G91") - g.command("G1X30F600", timeout=0.5) - ctx.sleep(1.0) - g.realtime(0x18) - st = wait_state(ctx, g, "Alarm", 5) - ctx.check(st is not None, "^X did not land in Alarm within 5 s") - p2 = st["MPos"] - moved2 = p2[0] - p1[0] - ev["abort_moved_mm"] = round(moved2, 3) - ctx.log("^X abort: state %s, moved %.3f mm of 30, position retained %s", st["state"], moved2, p2) - ctx.check(0.5 < moved2 < 29.5, "abort position not retained/plausible (%.3f mm)", moved2) - r = g.command("$X") - ctx.log("$X -> %s", r) - st = wait_state(ctx, g, "Idle", 5) - ctx.check(st is not None, "$X did not recover to Idle") - # A sender abort is not a lid cancel: it stops where it stopped and - # the head stays there. Only the lid/interlock policy returns to the - # job start, and an unasked-for return move would be a surprise to - # whoever pressed Stop. - text = drain_text(g, 2.0) - ev["abort_returned_home"] = "returned to the job start" in text - p3 = g.status_report()["MPos"] - ev["abort_drift_after_recovery_mm"] = round(abs(p3[0] - p2[0]), 3) - ctx.log("after $X: %s (moved %.3f mm since the abort)", p3, ev["abort_drift_after_recovery_mm"]) - ctx.check(not ev["abort_returned_home"], "a sender abort triggered the return-to-start motion") - ctx.check(ev["abort_drift_after_recovery_mm"] <= 0.05, - "the head moved %.3f mm on its own after a sender abort", - ev["abort_drift_after_recovery_mm"]) - # a jog after the abort proves the drivers are alive; return to start - back = -(p2[0] - start[0]) - r = g.command("$J=G91X%.3fF2400" % back) - ctx.check(not any(x.startswith("error") for x in r), "return jog refused: %s", r) - peak, states, st = wait_idle(ctx, g, 30) - ctx.check("TIMEOUT" not in states, "return jog did not complete: %s", states) - final = st["MPos"] - drift = abs(final[0] - start[0]) - ev["final_drift_mm"] = round(drift, 3) - ctx.log("returned: drift %.3f mm", drift) - ctx.check(drift <= 0.05, "position drift %.3f mm after cancel/abort/return", drift) - g.command("G90") - machine_idle(ctx) - - # ---------------------------------------------------------------- dead-man def _kernel_x_mm(ctx): @@ -656,7 +584,7 @@ def _return_x(ctx, delta_mm): "a kill during the web-service homing", subsystem="motion", kind="auto", mode="grbl", est_min=6, covers=_MOTION_COVERS + [("forgectrl", "src/main.c"), ("forgectrl", "init/**")], - requires=["motion.cancel-abort", "kernel.k1-k2"], + requires=["motion.pacing", "kernel.k1-k2"], steps=["Bed clear; the head needs 40 mm of free +X travel and must not be at the left rail. " "With cloud mode enabled the test also homes through the web service and kills the " "controller mid-homing (about a minute); the head ends wherever the homing was."], diff --git a/forgetest/forgetest/suite/setup.py b/forgetest/forgetest/suite/setup.py index 74c95e4..f422a7d 100644 --- a/forgetest/forgetest/suite/setup.py +++ b/forgetest/forgetest/suite/setup.py @@ -3,10 +3,10 @@ # https://community.openglow.org # SPDX-License-Identifier: MIT -"""setup.* - the first-run setup: the record and the -controller gate, the advisories, the account and its login, the HTTPS -boundary, SSH, the cloud switch, the machine's name, and the factory -return. +"""setup.* - what the setup tests share: the setup record and its +builders, the files of the data directory, a takeover that installs a +record, and the HTTPS client. The tests are setup_dark's and +setup_burns'; this module registers none. The daemon reads the setup record (setup.json in the data directory) and the account record (users) once, at its start, and @@ -27,19 +27,15 @@ import posixpath import http.client import json import os -import random -import socket import ssl import time import urllib.parse -from ..catalog import test from .. import hw RECORD = "setup.json" USERS = "users" SSH_FLAG = "ssh-enabled" -TLS_BASE = "https://127.0.0.1" # FORGECTRL_TLS_URL overrides (host tests) # The first run's form wizards, all at version 1 (wiz.c). FORM_WIZARDS = ("advisories", "account", "preferences", "machine", "cloud") @@ -52,8 +48,6 @@ REQUIRED_STEPS = REQUIRED_FORM_STEPS + ("cooling.flow",) # The setting that counts the intake fans: the gate holds without it. INTAKE_KEY = "cool_intake_fans" SAFETY_PHRASE = "I UNDERSTAND" -LOGIN_FAILS = 5 -LOGIN_LOCK_S = 30 # ----------------------------------------------------------------- files @@ -284,19 +278,6 @@ def decode(content): return text -def tls_base(): - return (os.environ.get("FORGECTRL_TLS_URL") or TLS_BASE).rstrip("/") - - -def tcp_open(host, port, timeout=2.0): - try: - s = socket.create_connection((host, port), timeout=timeout) - s.close() - return True - except OSError: - return False - - def session_from_cookie(value): """The ffsid session id out of a Set-Cookie value, or None.""" for part in (value or "").split(";"): @@ -318,218 +299,6 @@ def wiz(fc): return body -def mode(fc): - st, body = fc.get("/mode") - return body if (st == 200 and isinstance(body, dict)) else {} - - -def press_status(fc): - """GET /wiz/advisories/press, the page's poll at the press step: the - daemon takes a pressed button and records the acceptance on this - read, so a press nobody polls for stays 'pressed' and unaccepted.""" - st, body = fc.get("/wiz/advisories/press") - return body if (st == 200 and isinstance(body, dict)) else {} - - -def wiz_summary(w): - return {k: w.get(k) for k in ("first_run", "gate", "why", "recommended", - "advisories_complete", "acceptance_done", "account", - "fans_counted", "completed", "versions")} - - def recommended(w): """The steps a GET /wiz body recommends: {id: reason}.""" return {x.get("id"): x.get("reason") or "" for x in w.get("recommended") or [] if isinstance(x, dict)} - - -def wait_settled(ctx): - """The supervisor settled (the runner's own wait): the /mode body.""" - return ctx.takeover().wait_settled() - - -# -------------------------------------------------------------- the login - -@test("setup.account-login", title="The panel login over HTTPS", subsystem="setup", - kind="auto", est_min=2, - covers=[("forgectrl", "src/auth.*"), ("forgectrl", "src/session.*"), ("forgectrl", "src/users.*"), - ("forgectrl", "src/tls.*"), ("forgectrl", "src/peer.*"), ("forgectrl", "src/main.c"), - ("forgectrl", "src/ui/login.*")], - requires=["forgectrl.auth"], hardware="takeover", - description="The test makes its own account: the account record is moved aside under a " - "forgectrl restart, the account route creates a temporary one with a password " - "only the test knows, and the real record comes back under another restart at " - "the end, the system accounts replayed and the temporary home removed. The data " - "directory is set to 0700 first, as a strict umask leaves it: the render that makes " - "the account opens it for search and nothing else (0711), and tried as the account, " - "its home can be entered and written while the data directory cannot be listed and " - "the account record cannot be read. Over " - "HTTPS (self-signed, so unverified): GET /wiz reports no session and the " - "certificate fingerprint; POST /login with a wrong password is refused (401); " - "five failures lock the address (429, wait at most 30 s) and the right password " - "is refused inside the lock too; after the lock a wrong name is 401 and the " - "right pair sets the ffsid cookie (HttpOnly, Secure, SameSite=Strict); GET /wiz " - "with the cookie reports the session; POST /logout ends it.") -def account_login(ctx): - fc = ctx.forgectrl - ev = ctx.evidence - name = "forgetest" - pw = "".join(random.choice("abcdefghjkmnpqrstuvwxyz23456789") for _ in range(16)) - before = wiz(fc) - ev["account_before"] = (before.get("users") or {}).get("name") - homes = "/data/forgefirm/home" - dir_mode = os.stat(data_dir()).st_mode & 0o7777 - ev["data_dir_mode_found"] = "%04o" % dir_mode - try: - with installed(ctx, {users_path(): None}): - w = wiz(fc) - ctx.check(not (w.get("users") or {}).get("exists"), "an account still exists: %s", w.get("users")) - # the data directory as a strict umask leaves it: the account's render has to open it - os.chmod(data_dir(), 0o700) - st, body = fc.post("/wiz/account", data={"name": name, "password": pw}) - ctx.log("POST /wiz/account (temporary %r) -> %s %s", name, st, body if isinstance(body, dict) else "") - ctx.check(st == 200, "the temporary account was not created: %s %s", st, body) - w = wiz(fc) - ctx.check((w.get("users") or {}).get("name") == name, "the account reads %s", w.get("users")) - reach = home_reach(name) - ev["home_reach"] = reach - ev["data_dir_mode_after_render"] = "%04o" % (os.stat(data_dir()).st_mode & 0o7777) - ctx.log("as %s, from a data directory found 0700 (now %s): %s", name, ev["data_dir_mode_after_render"], reach) - ctx.check(reach.get("cd") == "ok" and reach.get("write") == "ok", - "the account cannot use its own home %s: %s", reach.get("home"), reach) - ctx.check(ev["data_dir_mode_after_render"] == "0711", "the render left the data directory at %s, expected " - "0711 (the search bit and nothing else)", ev["data_dir_mode_after_render"]) - ctx.check(reach.get("list_data_dir") == "EACCES" and reach.get("read_record") == "EACCES", - "the account reads what is not its own: %s", reach) - login_checks(ctx, name, pw) - finally: - # as found; the replay below is the product's own say on the mode - os.chmod(data_dir(), dir_mode) - rc, out = hw.initd("forgefirm-users", "reload") - ev["users_reload_rc"] = rc - ctx.log("forgefirm-users reload -> rc %s %s", rc, out.strip()[:200]) - if name != ev["account_before"]: - import shutil - shutil.rmtree(os.path.join(homes, name), ignore_errors=True) - after = wiz(fc) - ctx.check((after.get("users") or {}).get("name") == ev["account_before"], - "the account reads %r after the restore, was %r", (after.get("users") or {}).get("name"), - ev["account_before"]) - - -# Run as root: becomes the account and tries its own home, and what is -# not its own. One JSON line. -HOME_REACH = r''' -import errno, json, os, pwd, sys -p = pwd.getpwnam(sys.argv[1]) -os.setgroups([]) -os.setgid(p.pw_gid) -os.setuid(p.pw_uid) - - -def tried(fn): - try: - fn() - return "ok" - except OSError as e: - return errno.errorcode.get(e.errno, str(e.errno)) - - -def write(): - path = os.path.join(p.pw_dir, ".forgetest-reach") - with open(path, "w") as f: - f.write("x") - os.remove(path) - - -print(json.dumps({"uid": os.getuid(), "home": p.pw_dir, "cd": tried(lambda: os.chdir(p.pw_dir)), "write": tried(write), - "list_data_dir": tried(lambda: os.listdir(sys.argv[2])), - "read_record": tried(lambda: open(os.path.join(sys.argv[2], "users")).read())})) -''' - - -def home_reach(name): - """What the account can do with its own home and with the data - directory around it, tried as the account.""" - import subprocess - import sys - p = subprocess.run([sys.executable, "-c", HOME_REACH, name, data_dir()], capture_output=True, text=True, timeout=30) - try: - return json.loads(p.stdout) - except ValueError: - return {"error": (p.stderr or p.stdout).strip()[-200:]} - - -def login_checks(ctx, name, pw): - """The login rules against an account whose password the test knows.""" - ev = ctx.evidence - base = tls_base() - - st, body, hdrs = request(base, "GET", "/wiz") - w = decode(body) - ctx.log("GET %s/wiz -> %s", base, st) - ctx.check(st == 200 and isinstance(w, dict), "GET /wiz over HTTPS -> %s", st) - ctx.check(w.get("session") is False, "a session is reported without a cookie") - ev["tls_fingerprint"] = w.get("tls_fingerprint") - ctx.check(w.get("tls_fingerprint"), "GET /wiz carries no certificate fingerprint") - - def attempt(n, p): - st, body, hdrs = request(base, "POST", "/login", data={"name": n, "password": p}) - return st, decode(body), hdrs - - # a lock left by an earlier run: wait it out first - st, body, hdrs = attempt(name, "not-" + pw) - if st == 429: - wait = int((body or {}).get("wait") or LOGIN_LOCK_S) if isinstance(body, dict) else LOGIN_LOCK_S - ctx.log("the address is locked from before (%s s): waiting", wait) - ctx.sleep(min(wait, LOGIN_LOCK_S) + 1) - st, body, hdrs = attempt(name, "not-" + pw) - codes = [st] - ctx.check(st == 401, "a wrong password -> %s, expected 401", st) - for i in range(LOGIN_FAILS + 1): - st, body, hdrs = attempt(name, "not-%s-%d" % (pw, i)) - codes.append(st) - if st == 429: - break - ev["wrong_codes"] = codes - ctx.log("wrong password attempts -> %s", codes) - ctx.check(codes[-1] == 429 and all(c == 401 for c in codes[:-1]), - "the throttle did not lock after wrong attempts: %s", codes) - ctx.check(len(codes) - 1 <= LOGIN_FAILS, "the lock came after %d failures, the rule is %d", - len(codes) - 1, LOGIN_FAILS) - wait = body.get("wait") if isinstance(body, dict) else None - ev["wait"] = wait - ctx.check(isinstance(wait, int) and 0 < wait <= LOGIN_LOCK_S, "the lock reports wait=%r", wait) - st, body, hdrs = attempt(name, pw) - ev["right_inside_lock"] = st - ctx.check(st == 429, "the right password inside the lock -> %s, expected 429", st) - ctx.log("locked for %s s; the right password is refused inside the lock", wait) - ctx.sleep(wait + 1) - - st, body, hdrs = attempt("not-" + name, pw) - ev["wrong_name"] = st - ctx.check(st == 401, "a wrong name with the right password -> %s, expected 401", st) - st, body, hdrs = attempt(name, pw) - cookie = hdrs.get("set-cookie") or "" - sid = session_from_cookie(cookie) - ev["login"] = st - ev["cookie_flags"] = sorted(cookie_flags(cookie)) - ctx.log("POST /login (right) -> %s, cookie flags %s", st, ev["cookie_flags"]) - ctx.check(st == 200, "the right name and password -> %s, expected 200", st) - ctx.check(sid and len(sid) == 64 and all(c in "0123456789abcdef" for c in sid), - "no ffsid session id in Set-Cookie: %r", cookie) - ctx.check({"httponly", "secure", "samesite"} <= cookie_flags(cookie), - "the cookie lacks HttpOnly, Secure, or SameSite: %r", cookie) - ctx.check("samesite=strict" in cookie.lower(), "the cookie is not SameSite=Strict: %r", cookie) - st, body, hdrs = request(base, "GET", "/wiz", headers={"Cookie": "ffsid=" + sid}) - w = decode(body) - ctx.check(st == 200 and isinstance(w, dict) and w.get("session") is True, - "GET /wiz with the cookie does not report the session (%s %s)", st, - w.get("session") if isinstance(w, dict) else w) - st, body, hdrs = request(base, "POST", "/logout", headers={"Cookie": "ffsid=" + sid}) - ev["logout"] = st - ctx.check(st == 200, "POST /logout -> %s", st) - ctx.check("max-age=0" in (hdrs.get("set-cookie") or "").lower(), "the logout did not clear the cookie") - st, body, hdrs = request(base, "GET", "/wiz", headers={"Cookie": "ffsid=" + sid}) - w = decode(body) - ctx.check(isinstance(w, dict) and w.get("session") is False, "the session survives the logout") - ctx.log("session set by the login, seen by GET /wiz, ended by the logout") diff --git a/forgetest/tests/test_image.py b/forgetest/tests/test_image.py index 1fce39e..3a89d42 100644 --- a/forgetest/tests/test_image.py +++ b/forgetest/tests/test_image.py @@ -3,6 +3,7 @@ # https://community.openglow.org # SPDX-License-Identifier: MIT +import json import os import shutil import subprocess @@ -120,6 +121,89 @@ class MachineNameTests(unittest.TestCase): self.assertFalse(heading.startswith("#") or ".local" in issue, issue) +# forgefirm-users rendering a record, as the image runs it, in a private +# mount namespace as root: /run a fresh tmpfs, /etc read-only with the +# scratch tree's account files over the host's, and the scratch data +# directory over /data (made for the run where the host has none, as +# fflog_e2e.sh does, and removed after). The data directory is root's, as +# on the machine, whoever made the scratch tree. Then, as the account's +# own uid, what it can do with its home and with the data directory +# around it. Exit 97: the namespace could not be set up. +ACCOUNTS = r""" +T=$1; SCRIPT=$2 +made= +[ -d /data ] || { mkdir /data && made=1; } || exit 97 +chown -R 0:0 "$T/data" && + mount --make-rprivate / && mount -t tmpfs tmpfs /run && + mount -t overlay overlay -o "lowerdir=$T/etc:/etc" /etc && + mount --bind "$T/data" /data || { [ -z "$made" ] || rmdir /data; exit 97; } +sh "$SCRIPT" reload +echo "rc=$?" +echo "mode=$(stat -c %a /data/forgefirm)" +python3 - <<'PY' +import errno, json, os, pwd +p = pwd.getpwnam("bench") +os.setgroups([]) +os.setgid(p.pw_gid) +os.setuid(p.pw_uid) +def tried(fn): + try: + fn() + return "ok" + except OSError as e: + return errno.errorcode.get(e.errno, str(e.errno)) +def write(): + path = os.path.join(p.pw_dir, ".reach") + with open(path, "w") as f: + f.write("x") + os.remove(path) +print("reach=" + json.dumps({"uid": os.getuid(), "home": p.pw_dir, "cd": tried(lambda: os.chdir(p.pw_dir)), + "write": tried(write), "list_data_dir": tried(lambda: os.listdir("/data/forgefirm")), + "read_record": tried(lambda: open("/data/forgefirm/users").read())})) +PY +cd / +umount /data +[ -z "$made" ] || rmdir /data +rm -rf "$T/data" +""" + + +@unittest.skipUnless(os.path.isdir(LAYER) and shutil.which("unshare"), "needs Linux and a forgefirm checkout") +class AccountRenderTests(unittest.TestCase): + """forgefirm-users, the layer's own init script, rendering the record.""" + + def test_the_render_opens_the_data_directory_to_its_account_and_no_further(self): + tree = tempfile.mkdtemp(prefix="forgetest-users.") + self.addCleanup(shutil.rmtree, tree, ignore_errors=True) + os.makedirs(os.path.join(tree, "etc")) + for name, text in (("passwd", "root:x:0:0:root:/root:/bin/sh\n"), ("shadow", "root::19000:0:99999:7:::\n"), + ("group", "root:x:0:\n"), ("gshadow", "root:*::\n")): + with open(os.path.join(tree, "etc", name), "w") as f: + f.write(text) + data = os.path.join(tree, "data", "forgefirm") + os.makedirs(data) + os.chmod(data, 0o700) # as a strict umask leaves it + with open(os.path.join(data, "users"), "w") as f: + f.write("bench:$6$salt$hash:1000\n") + os.chmod(os.path.join(data, "users"), 0o600) + try: + p = subprocess.run(["sudo", "-n", "unshare", "-m", "sh", "-c", ACCOUNTS, "sh", tree, + os.path.join(LAYER, "forgefirm-users", "files", "forgefirm-users.init")], + capture_output=True, text=True, timeout=60) + except (OSError, subprocess.TimeoutExpired): + p = None + if p is None or "rc=" not in p.stdout: + if p is not None and p.returncode == 97: + raise AssertionError("the namespace was made but not set up: %s" % p.stderr.strip()) + self.skipTest("no namespace can be made here (root through sudo -n)") + self.assertEqual(field(p.stdout, "rc"), "0", p.stdout + p.stderr) + self.assertEqual(field(p.stdout, "mode"), "711", p.stdout) + reach = json.loads(field(p.stdout, "reach")) + self.assertEqual((reach["uid"], reach["home"]), (1000, "/data/forgefirm/home/bench"), reach) + self.assertEqual((reach["cd"], reach["write"]), ("ok", "ok"), reach) + self.assertEqual((reach["list_data_dir"], reach["read_record"]), ("EACCES", "EACCES"), reach) + + class NetworkBootTests(unittest.TestCase): STANZA = ("auto wlan0\n" "iface wlan0 inet dhcp\n" @@ -255,6 +339,14 @@ class RootfsLintTests(unittest.TestCase): self.put("etc/ssh/sshd_config_readonly", self.SSHD) self.put("etc/network/interfaces", "auto lo\niface lo inet loopback\n" + NetworkBootTests.STANZA) self.put("etc/nsswitch.conf", "hosts: files dns\n") + for name in ("forgectrl", "forgeext", "grblhal", "forgefirm-logging"): + self.put("etc/init.d/" + name, "#!/bin/sh\n", mode=0o755) + os.makedirs(self.path("etc/rc6.d")) + for link in ("rc5.d/S90forgectrl", "rc5.d/S91forgeext", "rc6.d/K09forgeext", "rc6.d/K80grblhal", + "rc6.d/K90forgectrl"): + os.symlink("../init.d/" + link.split("/")[1][3:], self.path("etc/" + link)) + self.put("etc/default/rcS", "VERBOSE=no\nROOTFS_READ_ONLY=yes\n") + self.put("etc/fstab", "/dev/root / auto ro 1 0\n/dev/mmcblk2p3 /data auto defaults 0 0\n") def path(self, rel): return os.path.join(self.root, rel) @@ -313,6 +405,33 @@ class RootfsLintTests(unittest.TestCase): os.makedirs(self.path("etc/avahi")) self.assertFails("mdns") + def test_the_extension_host_starting_before_forgectrl_fails(self): + os.rename(self.path("etc/rc5.d/S91forgeext"), self.path("etc/rc5.d/S89forgeext")) + self.assertFails("init-order") + + def test_no_logging_init_script_fails(self): + os.remove(self.path("etc/init.d/forgefirm-logging")) + self.assertFails("logging") + + def test_a_userspace_watchdog_init_script_fails(self): + self.put("etc/init.d/watchdog", "#!/bin/sh\n", mode=0o755) + self.assertFails("watchdog") + + def test_rcs_without_the_read_only_rootfs_fails(self): + self.put("etc/default/rcS", "VERBOSE=no\n") + self.assertFails("read-only") + + def test_a_dhcpv6_client_on_the_image_fails(self): + os.makedirs(self.path("sbin")) + os.symlink("/bin/busybox.nosuid", self.path("sbin/udhcpc6")) + self.assertFails("dhcpv6") + + def test_a_factory_slot_in_fstab_fails_a_release_image_only(self): + self.put("etc/fstab", "/dev/root / auto ro 1 0\n/dev/mmcblk2p1 /factory/img1 auto ro,noatime 0 0\n") + self.assertFails("factory", "release") + p = self.lint("dev") + self.assertEqual(p.returncode, 0, p.stderr) + if __name__ == "__main__": unittest.main() diff --git a/forgetest/tests/test_setup_suite.py b/forgetest/tests/test_setup_suite.py index 707b32d..0d80938 100644 --- a/forgetest/tests/test_setup_suite.py +++ b/forgetest/tests/test_setup_suite.py @@ -3,9 +3,9 @@ # https://community.openglow.org # SPDX-License-Identifier: MIT -"""The setup.* suite on the host: the registration (ids, kinds, the -takeover tests, the operator tests' hands), the record builders, the -cookie parsing, and the settle rule for a gated supervisor.""" +"""The setup.* suite on the host: the registration (the operator tests' +hands, the intake tests' precheck), the record builders, the cookie +parsing, and the settle rule for a gated supervisor.""" import json import os import shutil @@ -18,19 +18,11 @@ import helpers # noqa: E402 from forgetest import baseline, catalog # noqa: E402 from forgetest.suite import setup # noqa: E402 -IDS = ("setup.account-login",) - class RegistrationTests(unittest.TestCase): def setUp(self): self.reg = catalog.load_suite() - def test_every_test_is_registered_with_its_kind(self): - kinds = {tid: self.reg[tid].kind for tid in IDS if tid in self.reg} - self.assertEqual(sorted(kinds), sorted(IDS)) - for tid in IDS: - self.assertEqual(kinds[tid], "auto", tid) - def test_recommended_reads_the_status_document(self): w = {"recommended": [{"id": "laser.floor", "reason": "the tube was replaced"}, {"id": "laser.focus", "reason": "the tray was replaced"}, @@ -59,17 +51,6 @@ class RegistrationTests(unittest.TestCase): and t.kind != "auto" and not t.fixture_runnable(("button", "lid", "interlock"))) self.assertEqual(attended, ["setup.cloud-header-capture", "setup.focus"]) - def test_the_login_test_makes_its_own_account(self): - # No bench credentials, no precheck: the test installs a temporary - # account under a takeover and restores the real one. - t = self.reg["setup.account-login"] - self.assertIsNone(getattr(t, "precheck", None)) - self.assertEqual(t.hardware, "takeover") - with open(setup.__file__) as f: - src = f.read() - for k in ("FORGETEST_LOGIN_NAME", "FORGETEST_LOGIN_PASSWORD"): - self.assertNotIn(k, src) - class RecordTests(unittest.TestCase): STATUS = {"sheet_id": "ABCDE-FGHIJ", diff --git a/meta-forgefirm/recipes-forgefirm/images/rootfs-lint.sh b/meta-forgefirm/recipes-forgefirm/images/rootfs-lint.sh index 97e73f5..e0fc3b2 100755 --- a/meta-forgefirm/recipes-forgefirm/images/rootfs-lint.sh +++ b/meta-forgefirm/recipes-forgefirm/images/rootfs-lint.sh @@ -29,6 +29,18 @@ # mdns no mDNS responder: no /etc/avahi, no avahi-daemon, no # libnss_mdns, and the hosts line of nsswitch.conf names no # mdns source. +# init-order runlevel 5 starts forgectrl before forgeext, and runlevel 6 +# stops grblhal before forgectrl and forgeext before +# forgectrl: the links sort in that order. +# logging a ForgeFIRM logging init script is on the image +# (forgefirm-logging or forgefirm-logrotate). +# watchdog no userspace watchdog init script (/etc/init.d/watchdog): +# the kernel feeds the hardware watchdog. +# read-only /etc/default/rcS sets ROOTFS_READ_ONLY=yes. +# dhcpv6 no DHCPv6 client (udhcpc6, dhcp6c, odhcp6c) in a bin +# directory, and no /usr/share/udhcpc/default6.script. +# factory on a release image, no /etc/fstab line mounts anything +# under /factory/ (the dev image mounts the factory slots). root=${1:?rootfs} kind=${2:?release or dev} @@ -141,4 +153,52 @@ if active "$root/etc/nsswitch.conf" | grep -E '^[[:space:]]*hosts:' | grep -q 'm fail mdns "nsswitch.conf resolves hosts through mdns" fi +# ------------------------------------------------------------ init-order +# link : the first link of that kind whose name +# ends in name, or nothing +link() { + for l in "$root/etc/$1"/"$2"*"$3"; do + [ -L "$l" ] || [ -e "$l" ] || continue + echo "${l##*/}" + return + done +} +# before : a and b are both there and a sorts first +before() { + if [ -z "$2" ] || [ -z "$3" ]; then + fail init-order "$1: a link is missing (${2:-none}, ${3:-none})" + elif [ "$(printf '%s\n%s\n' "$2" "$3" | LC_ALL=C sort | head -n 1)" != "$2" ] || [ "$2" = "$3" ]; then + fail init-order "$1: $2 does not sort before $3" + fi +} +before "rc5.d starts forgectrl before forgeext" "$(link rc5.d S forgectrl)" "$(link rc5.d S forgeext)" +before "rc6.d stops grblhal before forgectrl" "$(link rc6.d K grblhal)" "$(link rc6.d K forgectrl)" +before "rc6.d stops forgeext before forgectrl" "$(link rc6.d K forgeext)" "$(link rc6.d K forgectrl)" + +# --------------------------------------------------------------- logging +[ -e "$root/etc/init.d/forgefirm-logging" ] || [ -e "$root/etc/init.d/forgefirm-logrotate" ] \ + || fail logging "no ForgeFIRM logging init script (forgefirm-logging, forgefirm-logrotate)" + +# -------------------------------------------------------------- watchdog +[ -e "$root/etc/init.d/watchdog" ] && fail watchdog "/etc/init.d/watchdog is on the image" + +# ------------------------------------------------------------- read-only +grep -qx 'ROOTFS_READ_ONLY=yes' "$root/etc/default/rcS" 2>/dev/null \ + || fail read-only "/etc/default/rcS does not set ROOTFS_READ_ONLY=yes" + +# ---------------------------------------------------------------- dhcpv6 +for d in bin sbin usr/bin usr/sbin; do + for p in udhcpc6 dhcp6c odhcp6c; do + if [ -e "$root/$d/$p" ] || [ -L "$root/$d/$p" ]; then + fail dhcpv6 "/$d/$p is on the image" + fi + done +done +[ -e "$root/usr/share/udhcpc/default6.script" ] && fail dhcpv6 "/usr/share/udhcpc/default6.script is on the image" + +# --------------------------------------------------------------- factory +if [ "$kind" = release ] && active "$root/etc/fstab" | awk '$2 ~ /^\/factory\// { f = 1 } END { exit !f }'; then + fail factory "/etc/fstab mounts a factory slot under /factory/ on a release image" +fi + exit $failed diff --git a/scripts/bench/laser_lifecycle_test.py b/scripts/bench/laser_lifecycle_test.py index 0cd4846..01a6a63 100644 --- a/scripts/bench/laser_lifecycle_test.py +++ b/scripts/bench/laser_lifecycle_test.py @@ -64,6 +64,10 @@ reported messages: cancels it from the hold - reason reported, the reset banner, no alarm, the return to the job start - and a later press resumes nothing + 17. a jog cancel (0x85) stops a jog short of its target, Idle; a ^X + mid-move lands in Alarm with the position kept and no return to the + job start; $X recovers to Idle and the next jog ships steps + (GFSINK_DUMP) The disarm grace is shortened via a temp config (GFHOME_CONF), the cooling verdict is published hermetically (GF_VERDICT_FILE), the same @@ -214,9 +218,11 @@ class Session: """One controller process with the lifecycle overrides applied.""" def __init__(self, name, fire_ok=True, disarm_s=2, switches=None, conf_extra="", - armed_ack=True, ack_after_s=0.0): + armed_ack=True, ack_after_s=0.0, dump=False): self.name = name self.workdir = tempfile.mkdtemp(prefix="laser-lifecycle-") + # The stream the controller ships, when the case reads it. + self.dump = os.path.join(self.workdir, "stream") if dump else None conf = os.path.join(self.workdir, "forgefirm.conf") with open(conf, "w") as f: # The fan grace at 0 keeps the arm's acknowledgment budget at its @@ -228,6 +234,9 @@ class Session: GF_STATE_DIR=self.workdir, FFLOG_STDERR="1") env.pop("GFSINK", None) env.pop("GF_SWITCH_FILE", None) + env.pop("GFSINK_DUMP", None) + if self.dump: + env["GFSINK_DUMP"] = self.dump self.switch_file = None if switches is not None: self.switch_file = os.path.join(self.workdir, "switches") @@ -868,6 +877,64 @@ def test_pause_then_lid_cancel_returns_to_the_job_start(): s.close() +def test_cancel_and_abort(): + """Rule 17: a jog cancel (0x85) stops a jog short of its target and + leaves the controller Idle. A ^X mid-move is a controlled abort: Alarm, + the position kept, and no return to the job start (that move is the lid + policy's alone). $X recovers to Idle, the head does not move on its + own, and the next jog ships steps.""" + s = Session("cancel-abort", disarm_s=60, dump=True) + try: + x0 = s.mpos_x() + if x0 is None: + fail("[cancel-abort] no position in the status report") + s.send_raw("$J=G91X40F2400") + if not s.wait_state("Jog", 3): + fail("[cancel-abort] the jog never reported Jog (state %s)" % s.state()) + time.sleep(0.3) + s.sock.sendall(b"\x85") + if not s.wait_state("Idle", 5): + fail("[cancel-abort] not Idle after the jog cancel (state %s)" % s.state()) + x1 = s.mpos_x() + if x1 is None or not 0.5 < x1 - x0 < 39.0: + fail("[cancel-abort] the jog cancel did not stop short of the target (X %s from %.3f)" + % (x1, x0)) + + s.send_raw("M5") + s.send_raw("G91") + s.send_raw("G1 X30 F600") # 3 s + if not s.wait_state("Run", 5): + fail("[cancel-abort] the move never reported Run (state %s)" % s.state()) + time.sleep(0.5) + s.sock.sendall(b"\x18") + if not s.wait_state("Alarm", 5): + fail("[cancel-abort] ^X mid-move did not land in Alarm (state %s)" % s.state()) + x2 = s.mpos_x() + if x2 is None or not 0.5 < x2 - x1 < 29.5: + fail("[cancel-abort] the abort did not keep a plausible position (X %s from %.3f)" % (x2, x1)) + send_line(s.sock, "$X", s.log) + if not s.wait_state("Idle", 5): + fail("[cancel-abort] $X did not recover to Idle (state %s)" % s.state()) + read_avail(s.sock, s.log, 1.0) + if "returned to the job start" in "".join(s.log): + fail("[cancel-abort] a sender abort triggered the return to the job start") + x3 = s.mpos_x() + if x3 is None or abs(x3 - x2) > 0.05: + fail("[cancel-abort] the head moved on its own after the abort (X %s, was %.3f)" % (x3, x2)) + shipped = os.path.getsize(s.dump) if os.path.exists(s.dump) else 0 + s.send_raw("$J=G91X-5F2400") + if not s.wait_state("Jog", 3) or not s.wait_state("Idle", 5): + fail("[cancel-abort] the jog after $X did not run (state %s)" % s.state()) + grew = (os.path.getsize(s.dump) if os.path.exists(s.dump) else 0) - shipped + if grew <= 0: + fail("[cancel-abort] the jog after $X shipped no steps") + print("PASS [cancel-abort]: 0x85 stopped the jog at %.3f mm of 40, Idle; ^X mid-move landed in " + "Alarm at %.3f mm with no return; $X recovered to Idle and the next jog shipped %d bytes" + % (x1 - x0, x2 - x1, grew)) + finally: + s.close() + + def test_lid_cancel_from_hold(): """Rule 16: a job paused on the button is not resumable past a lid open. The lid cancels it from the hold as it does from Run - the @@ -1008,6 +1075,7 @@ def main(): test_button_pause_resume() test_lid_cancels_and_returns() test_pause_then_lid_cancel_returns_to_the_job_start() + test_cancel_and_abort() test_lid_cancel_from_hold() test_lid_policy_hold() test_sigterm_mid_job() diff --git a/scripts/release.sh b/scripts/release.sh index 98735ca..8d723b9 100644 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -241,18 +241,14 @@ else fi # Root policy gate. The release image ships root WITHOUT a password (the -# serial console is the recovery path) and sshd refuses root and empty -# passwords: PermitRootLogin no and PermitEmptyPasswords no must be -# active (uncommented) in the built sshd_config, and root's shadow field -# must be empty. Read the built files, not the recipes: this catches a -# drift however it got in (recipe, local.conf, an inherited class). +# serial console is the recovery path): root's shadow field must be empty. +# sshd's refusal of root and of empty passwords is the rootfs lint's +# (meta-forgefirm/recipes-forgefirm/images/rootfs-lint.sh), which fails +# the build. Read the built file, not the recipes: this catches a drift +# however it got in (recipe, local.conf, an inherited class). SSHD_CONFIG=$(debugfs -R "cat /etc/ssh/sshd_config" "$EXT4" 2>/dev/null) [ -n "$SSHD_CONFIG" ] \ || die "release rootfs carries no /etc/ssh/sshd_config" -printf '%s\n' "$SSHD_CONFIG" | grep -Eq '^PermitRootLogin[[:space:]]+no[[:space:]]*$' \ - || die "release sshd_config has no active 'PermitRootLogin no' (recipes-connectivity/openssh drift?)" -printf '%s\n' "$SSHD_CONFIG" | grep -Eq '^PermitEmptyPasswords[[:space:]]+no[[:space:]]*$' \ - || die "release sshd_config has no active 'PermitEmptyPasswords no' (recipes-connectivity/openssh drift?)" ROOT_SHADOW=$(debugfs -R "cat /etc/shadow" "$EXT4" 2>/dev/null \ | awk -F: '$1=="root"{print; exit}') [ -n "$ROOT_SHADOW" ] \ @@ -260,27 +256,22 @@ ROOT_SHADOW=$(debugfs -R "cat /etc/shadow" "$EXT4" 2>/dev/null \ ROOT_PW=$(printf '%s\n' "$ROOT_SHADOW" | awk -F: '{print $2}') [ -z "$ROOT_PW" ] \ || die "release rootfs has a non-empty root password field: the policy is an empty field (empty-root-password in forgefirm-image.bb); a build drift" -echo "root policy gate OK (root field empty; sshd refuses root and empty passwords)" +echo "root policy gate OK (root field empty)" # Read-only rootfs gate. The release rootfs mounts read-only: the root -# line of the built fstab carries ro, the rcS default agrees (the -# read-only-rootfs image feature), no factory-slot mount is in the -# release fstab (those belong to the dev image), and sshd keeps its host -# keys on /data, where the read-only rootfs cannot hold them. +# line of the built fstab carries ro, and sshd keeps its host keys on +# /data, where the read-only rootfs cannot hold them. rcS's +# ROOTFS_READ_ONLY and the fstab with no factory slot are the rootfs +# lint's. FSTAB=$(debugfs -R "cat /etc/fstab" "$EXT4" 2>/dev/null) [ -n "$FSTAB" ] \ || die "release rootfs carries no /etc/fstab" printf '%s\n' "$FSTAB" | awk '$1 == "/dev/root" && $2 == "/" { print $4 }' \ | grep -Eq '(^|,)ro(,|$)' \ || die "release fstab does not mount / read-only (base-files fstab or read-only-rootfs drift?)" -printf '%s\n' "$FSTAB" | grep -Eq '^[^#]*[[:space:]]/factory/' \ - && die "release fstab mounts a factory slot under /factory (dev image only)" -RCS=$(debugfs -R "cat /etc/default/rcS" "$EXT4" 2>/dev/null) -printf '%s\n' "$RCS" | grep -q '^ROOTFS_READ_ONLY=yes$' \ - || die "release rcS has no ROOTFS_READ_ONLY=yes (read-only-rootfs image feature drift?)" [ "$(printf '%s\n' "$SSHD_CONFIG" | grep -c '^HostKey /data/forgefirm/ssh/')" = 3 ] \ || die "release sshd_config does not keep the host keys under /data/forgefirm/ssh (recipes-connectivity/openssh drift?)" -echo "read-only rootfs gate OK (/ ro, no /factory mounts, host keys on /data)" +echo "read-only rootfs gate OK (/ ro, host keys on /data)" # Config-level guard: debug-tweaks must not sit in the shared kas config, # where it would apply to every target including the release image.