4 Commits
Author SHA1 Message Date
ScottW514 c3da9fa1bd Catalog: setup.account-login, motion.cancel-abort, forgectrl.settings-bounds and exthost.service leave; the rootfs lint takes image.health's file checks
The catalog is 43. Each test leaves for a host proof that its
component's CI runs at the pins:

- setup.account-login: the login rules are host_routes.sh's (forgectrl
  6c2bb38); the render is test_image.py's new AccountRenderTests, which
  runs the layer's forgefirm-users.init with reload in a private mount
  namespace as root (a fresh /run, the scratch account files over /etc,
  the scratch data directory, root's, over /data): the data directory
  found 0700 is 0711 after the render, and as the account's own uid the
  home is entered and written while listing the data directory and
  reading the users record are EACCES. A control without the script's
  chmod go+x fails it (700 != 711). forgectrl.auth's cover widens to
  src/ui/login.*, since login.html had no other cover.
- motion.cancel-abort: laser_lifecycle_test.py's new rule 17 on the
  null-sink controller: 0x85 stops a jog short of its target, Idle; ^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 (counted
  in GFSINK_DUMP). It passes on the driver at its pin, badc832.
  motion.deadman requires motion.pacing in its place.
- forgectrl.settings-bounds: host_routes.sh refuses the out-of-range and
  over-length values with the settings file byte for byte the same, and
  the lid lamp under GF_SYSFS_ROOT follows lid_lamp_idle (forgectrl
  6c2bb38).
- exthost.service: forgeext's host tests at the pinned bb75b23 -
  sandbox_test (the confinement from inside, the pulse device, the
  settings file and the setup record refused), run_test.py (the API,
  safe mode, the master switch, and the killed host under the init
  script's own wrapper), install_test.py and sdk_test.py.
  exthost.armed-freeze requires exthost.platform in its place.

HOST_TESTED rows for each path the lint named once exthost.service
left: forgectrl src/logs.* to logs_test.c and
docs/advisories/extensions.md to host_setup.sh; forgeext src/api.*,
caps.*, evfeed.*, holdkeep.*, httpreq.*, manifest.*, quota.*,
sandbox.*, settings.*, state.* to their unit tests, netrules.* to
netrules_test.py, call.* to sdk_test.py, install.*, pkg.* and index.* to
install_test.py, and main.c, fflog.* and CMakeLists.txt to run_test.py.
test_manifest's behavioral-path case is unaffected.

The rootfs lint takes image.health's file checks (rules init-order,
logging, watchdog, read-only, dhcpv6, and on a release image factory),
each proved in RootfsLintTests by a drift that fails it. image.health
keeps the live half: the rendered issue and motd against the running
manifest, one host and no pool process, watchdog0, no userspace
watchdog running, the mounts, the dev image's factory slots read-only,
no DHCPv6 client running, the DHCP client's -b and parent, ifup, the
getty, and IPv6. release.sh drops its PermitRootLogin and
PermitEmptyPasswords greps, its ROOTFS_READ_ONLY check and its /factory
fstab check, which the lint holds; it keeps the empty root shadow
field, / mounted ro, and the three HostKey lines under /data.

setup.py registers no test now: its helpers the deleted tests alone
called go (tls_base, tcp_open, mode, press_status, wiz_summary,
wait_settled, the login constants), and test_setup_suite.py keeps the
cases of what remains.

Fingerprints: image.health's moves (its text), every test in
exthost.py, forgectrl.py and motion.py moves with its module text, and
the layer content hash moves with rootfs-lint.sh: the next image
inherits no campaign.

Proof: the coverage lint over the tree manifest from the pins finds 0
uncovered paths and 0 host-tested row problems (43 tests); pyflakes on
the touched files is clean; forgetest's unit suite under Linux passes
(334), test_image.py's 28 among them. The lint passes image
20261015142442's release tree as release and its dev tree as dev, and
refuses the dev tree as release (sshd, factory).
laser_lifecycle_test.py passes whole on the null-sink build of badc832.
2026-10-15 14:02:20 -04:00
ScottW514 56aa164708 Pin forgeext 0.1.4 on the pushed head
- forgeext e09f7b8 -> bb75b23 (0.1.3 -> 0.1.4): sandbox_test opens the
  machine's own files from inside the sandbox - /dev/glowforge,
  /data/forgefirm/forgefirm.conf, /data/forgefirm/setup.json - and
  requires EACCES of each (ENOENT is no pass; a file the host lacks is
  made for the run, readable by every account, and removed after).
  Test-only; nothing the image installs changes.

Proof: forgeext's CI run on bb75b23 (38780750106) is green on a kernel
with Landlock ABI 7; its log shows the three files made for the run and
each open refused with EACCES (sandbox_test, 0 failures). bitbake -c
fetch forgeext from this pin succeeds (2 tasks).
2026-10-15 13:57:52 -04:00
ScottW514 df5ae151ab Pin forgeext 0.1.3 on the pushed head
- forgeext 31cdb40 -> e09f7b8 (0.1.2 -> 0.1.3): run_test's last phase
  runs the host under init/forgeext.init's own wrapper; a SIGKILL of the
  host takes its service with it at once, the wrapper starts the host
  again, and the service runs again under a new pid. Test-only; nothing
  the image installs changes.

Proof: forgeext's CI run on e09f7b8 (38777839870) is green on a kernel
with Landlock ABI 7, and the phase's six checks passed in it (run_test,
0 failures). bitbake -c fetch forgeext from this pin succeeds (2 tasks).
2026-10-15 13:37:42 -04:00
ScottW514 10ae7bf39c Pin forgectrl 0.1.33 on the pushed head
- forgectrl 22a2cfc -> 6c2bb38 (0.1.32 -> 0.1.33): cam.c resolves the
  lid and head lamps under GF_SYSFS_ROOT (unset: /sys/glowforge/, the
  paths as they were), and host_routes.sh proves the lid lamp follows
  lid_lamp_idle: a level is written, a refused value writes nothing, a
  cleared key writes the default 236.

Proof: forgectrl's CI run on 6c2bb38 (38769524512) is green, and
host_routes.sh ran in it. bitbake -c fetch forgectrl from this pin
succeeds (2 tasks). The tree manifest from the pins holds forgectrl at
6c2bb38 (229 files); the coverage lint finds no uncovered path and no
row problem (47 tests); check-ui-vendor finds the shared UI files
identical; test_tree_manifest passes; the gate self-check refuses the
empty artifact (rc 1).
2026-10-15 12:50:22 -04:00
13 changed files with 321 additions and 826 deletions
+32 -2
View File
@@ -181,9 +181,11 @@ HOST_TESTED = [
("forgectrl", "src/paths.h", "forgectrl:tests/host_routes.sh", "forgectrl:.github/workflows/build.yml"), ("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 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 # the press step breathing teal, the press taken as the acceptance, the
# form steps through main.c's hooks (extensions.md stays with # form steps through main.c's hooks; and the on-demand Extensions
# exthost.service, which covers it). # advisory, accepted at its hash with the typed phrase.
("forgectrl", "src/advisories.*", "forgectrl:tests/host_setup.sh", "forgectrl:.github/workflows/build.yml"), ("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", "docs/advisories/safety-and-risk.md", "forgectrl:tests/host_setup.sh",
"forgectrl:.github/workflows/build.yml"), "forgectrl:.github/workflows/build.yml"),
("forgectrl", "docs/advisories/licenses.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 # store that holds only hashes, and the extension host's credential
# (mode 0600, exactly the host's capabilities). # (mode 0600, exactly the host's capabilities).
("forgectrl", "src/tokens.*", "forgectrl:tests/tokens_test.c", "forgectrl:.github/workflows/build.yml"), ("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 # This repository's root, when the package runs from a checkout of it; a
+3 -323
View File
@@ -8,7 +8,6 @@ import json
import os import os
import re import re
import signal import signal
import stat
import subprocess import subprocess
import sys import sys
import time import time
@@ -488,8 +487,6 @@ FWUP = "/usr/bin/fwup"
EXT_ROOT = "/data/forgefirm/ext" EXT_ROOT = "/data/forgefirm/ext"
HOST_STATUS = "/run/forgefirm/ext/status.json" HOST_STATUS = "/run/forgefirm/ext/status.json"
SAFE_FILE = "/run/forgefirm/ext-safe" 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_ID = "org.forgetest.reference"
REF_KEY = "forgetest-reference" REF_KEY = "forgetest-reference"
REF_DEST = "192.0.2.1" # TEST-NET-1: declared so that port 443 is, and never dialed 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") 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 # ------------------------------------------------- the armed window
def _frozen(id_): def _frozen(id_):
@@ -1170,7 +850,7 @@ def _frozen(id_):
covers=[("forgeext", "src/super.*"), ("forgeext", "src/run.*"), ("forgeext", "src/machine.*"), covers=[("forgeext", "src/super.*"), ("forgeext", "src/run.*"), ("forgeext", "src/machine.*"),
("forgeext", "src/cgroup.*"), ("forgectrl", "src/cool.*"), ("forgectrl", "src/cam.*"), ("forgeext", "src/cgroup.*"), ("forgectrl", "src/cool.*"), ("forgectrl", "src/cam.*"),
("forgectrl", "src/main.c")], ("forgectrl", "src/main.c")],
requires=["exthost.service"], actions=["button"], requires=["exthost.platform"], actions=["button"],
steps=[OFFLINE_STEP, steps=[OFFLINE_STEP,
"Bed clear (the job is dark: a 30 s square at S0, nothing fires). Press the button " "Bed clear (the job is dark: a 30 s square at S0, nothing fires). Press the button "
"when it lights."], "when it lights."],
@@ -1186,8 +866,8 @@ def _frozen(id_):
"service is the same process throughout. A background capture is refused in words " "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 " "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 " "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 " "thread priority does not cover. The package, the key, the setting and the setup "
"back.") "record are put back as found, the record under a forgectrl restart.")
def armed_freeze(ctx): def armed_freeze(ctx):
import tempfile import tempfile
import threading import threading
+1 -104
View File
@@ -4,9 +4,7 @@
# SPDX-License-Identifier: MIT # SPDX-License-Identifier: MIT
"""forgectrl.* - the machine-services daemon's API, access control, and panel.""" """forgectrl.* - the machine-services daemon's API, access control, and panel."""
import json
import socket import socket
import time
from ..catalog import test from ..catalog import test
@@ -15,7 +13,7 @@ from ..catalog import test
# write with the token), so both are the guard's domain. # write with the token), so both are the guard's domain.
_COVERS_AUTH = [("forgectrl", "src/auth.*"), ("forgectrl", "src/peer.*"), ("forgectrl", "src/main.c"), _COVERS_AUTH = [("forgectrl", "src/auth.*"), ("forgectrl", "src/peer.*"), ("forgectrl", "src/main.c"),
("forgectrl", "src/session.*"), ("forgectrl", "src/users.*"), ("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(): def lan_ip():
@@ -210,104 +208,3 @@ def auth(ctx):
if st == 302: if st == 302:
ctx.check(loc == "/login?next=/setup%3Fstep%3Dlaser.focus", ctx.check(loc == "/login?next=/setup%3Fstep%3Dlaser.focus",
"the login redirect does not carry the path: %r", loc) "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)
+14 -42
View File
@@ -4,11 +4,9 @@
# SPDX-License-Identifier: MIT # SPDX-License-Identifier: MIT
"""image.* - the post-flash health of the running image (always-required).""" """image.* - the post-flash health of the running image (always-required)."""
import glob
import gzip import gzip
import mmap import mmap
import os import os
import shutil
import struct import struct
import re import re
import stat import stat
@@ -123,15 +121,15 @@ def fds_of(pid):
"the daemon ownership, the board temperatures /status reports (the chassis and " "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 " "the SoC die in a plausible range, the supply as a 10-bit count, the CPU "
"unthrottled), " "unthrottled), "
"the init ordering, the extension host as one process that starts after forgectrl and " "the extension host as one process on a machine with no package installed and "
"stops before it on a machine with no package installed and nothing running under a " "nothing running under a pool account, no userspace watchdog running, "
"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 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 " "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 " "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 " "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 " "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 " "kernel's own: enabled on wlan0, router advertisements accepted, a link-local "
"address up (a global address is logged, not checked).") "address up (a global address is logged, not checked).")
def image_health(ctx): def image_health(ctx):
@@ -284,17 +282,8 @@ def image_health(ctx):
ctx.check(temps.get("soc_throttle") == 0, ctx.check(temps.get("soc_throttle") == 0,
"the CPU is throttled (or its cooling device is missing) at idle: %s", temps) "the CPU is throttled (or its cooling device is missing) at idle: %s", temps)
# 5. init ordering: controllers stop before the daemon # 5. the extension host: one process, and a campaign's machine is
k = sorted(os.path.basename(p) for p in glob.glob("/etc/rc6.d/K*")) # extension-free (its init order is the rootfs lint's)
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
host = [] host = []
for pid in hw.pidof("forgeext"): for pid in hw.pidof("forgeext"):
argv = _read("/proc/%d/cmdline" % pid, "").split("\0") argv = _read("/proc/%d/cmdline" % pid, "").split("\0")
@@ -302,13 +291,6 @@ def image_health(ctx):
host.append(pid) host.append(pid)
ev["extension_host_pids"] = host ev["extension_host_pids"] = host
ctx.check(len(host) == 1, "the extension host is not one running process: %s", 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["extension_packages"] = hw.ext_packages()
ev["pool_processes"] = len(hw.pool_pids()) 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"]) 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"]) ev["extension_packages"])
ctx.check(not ev["pool_processes"], "%d process(es) run under extension pool accounts", ev["pool_processes"]) 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 # 6. no userspace watchdog daemon running (the logging init and the
logging = [n for n in ("forgefirm-logging", "forgefirm-logrotate") if os.path.exists("/etc/init.d/" + n)] # absent watchdog init script are the rootfs lint's); kernel threads
ev["logging_init"] = logging # (the imx2_wdt kthread is expected) have an empty cmdline
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
user_wd = [p for p in hw.pidof("watchdog") if _read("/proc/%d/cmdline" % p, "")] user_wd = [p for p in hw.pidof("watchdog") if _read("/proc/%d/cmdline" % p, "")]
ev["userspace_watchdog_pids"] = user_wd ev["userspace_watchdog_pids"] = user_wd
ctx.check(not user_wd, "a userspace watchdog daemon is running: %s", 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, # 8. the mounts: the rootfs read-only, /data the writable partition,
# the state a read-only rootfs hands off (the read-only-rootfs image # the state a read-only rootfs hands off (the read-only-rootfs image
# feature, forgefirm-users, forgefirm-hostname, forgefirm-banner, the # feature, forgefirm-users, forgefirm-hostname, forgefirm-banner, the
# sshd host keys). The # sshd host keys). The dev image's factory slots under /factory are
# dev image alone mounts the factory slots under /factory. # read-only; rcS's ROOTFS_READ_ONLY and the release fstab with no
# factory slot are the rootfs lint's.
mounts = {} mounts = {}
for line in (_read("/proc/mounts", "") or "").splitlines(): for line in (_read("/proc/mounts", "") or "").splitlines():
parts = line.split() 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("/data"), "/data is not mounted read-write: %s", mounts.get("/data"))
ctx.check("rw" in mount_opts("/var/lib"), 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")) "/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/")) factory = sorted(p for p in mounts if p.startswith("/factory/"))
ev["factory_mounts"] = factory ev["factory_mounts"] = factory
dev_image = os.path.exists("/etc/forgefirm-dev") dev_image = os.path.exists("/etc/forgefirm-dev")
@@ -382,8 +360,6 @@ def image_health(ctx):
if os.path.exists("/dev/mmcblk2p%d" % n): if os.path.exists("/dev/mmcblk2p%d" % n):
p = "/factory/img%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)) 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"): if os.path.isfile("/data/forgefirm/users"):
names = [l.split(":")[0] for l in (_read("/data/forgefirm/users", "") or "").splitlines() names = [l.split(":")[0] for l in (_read("/data/forgefirm/users", "") or "").splitlines()
if l.strip() and not l.startswith("#")] if l.strip() and not l.startswith("#")]
@@ -428,14 +404,10 @@ def image_health(ctx):
for pid, ppid, _comm, cmd in v4: for pid, ppid, _comm, cmd in v4:
ctx.check("-b" in cmd.split(), "the DHCP client runs without -b: %s", cmd) 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) 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] alive = [p[3] for p in procs if p[2] in DHCP6_CLIENTS]
ev["dhcp6_running"] = alive ev["dhcp6_running"] = alive
ctx.check(not alive, "a DHCPv6 client is running: %s", 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 # IPv6 is the kernel's own. A global address is evidence only: a
# network whose router advertisement offers no SLAAC prefix gives none. # network whose router advertisement offers no SLAAC prefix gives none.
conf = "/proc/sys/net/ipv6/conf/wlan0/" conf = "/proc/sys/net/ipv6/conf/wlan0/"
+1 -73
View File
@@ -559,78 +559,6 @@ def _wait_controller(ctx, fc, states, timeout, motion=None):
return m if isinstance(m, dict) else {} 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 # ---------------------------------------------------------------- dead-man
def _kernel_x_mm(ctx): def _kernel_x_mm(ctx):
@@ -656,7 +584,7 @@ def _return_x(ctx, delta_mm):
"a kill during the web-service homing", "a kill during the web-service homing",
subsystem="motion", kind="auto", mode="grbl", est_min=6, subsystem="motion", kind="auto", mode="grbl", est_min=6,
covers=_MOTION_COVERS + [("forgectrl", "src/main.c"), ("forgectrl", "init/**")], 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. " 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 " "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."], "controller mid-homing (about a minute); the head ends wherever the homing was."],
+4 -235
View File
@@ -3,10 +3,10 @@
# https://community.openglow.org # https://community.openglow.org
# SPDX-License-Identifier: MIT # SPDX-License-Identifier: MIT
"""setup.* - the first-run setup: the record and the """setup.* - what the setup tests share: the setup record and its
controller gate, the advisories, the account and its login, the HTTPS builders, the files of the data directory, a takeover that installs a
boundary, SSH, the cloud switch, the machine's name, and the factory record, and the HTTPS client. The tests are setup_dark's and
return. setup_burns'; this module registers none.
The daemon reads the setup record (setup.json in the The daemon reads the setup record (setup.json in the
data directory) and the account record (users) once, at its start, and data directory) and the account record (users) once, at its start, and
@@ -27,19 +27,15 @@ import posixpath
import http.client import http.client
import json import json
import os import os
import random
import socket
import ssl import ssl
import time import time
import urllib.parse import urllib.parse
from ..catalog import test
from .. import hw from .. import hw
RECORD = "setup.json" RECORD = "setup.json"
USERS = "users" USERS = "users"
SSH_FLAG = "ssh-enabled" 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). # The first run's form wizards, all at version 1 (wiz.c).
FORM_WIZARDS = ("advisories", "account", "preferences", "machine", "cloud") 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. # The setting that counts the intake fans: the gate holds without it.
INTAKE_KEY = "cool_intake_fans" INTAKE_KEY = "cool_intake_fans"
SAFETY_PHRASE = "I UNDERSTAND" SAFETY_PHRASE = "I UNDERSTAND"
LOGIN_FAILS = 5
LOGIN_LOCK_S = 30
# ----------------------------------------------------------------- files # ----------------------------------------------------------------- files
@@ -284,19 +278,6 @@ def decode(content):
return text 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): def session_from_cookie(value):
"""The ffsid session id out of a Set-Cookie value, or None.""" """The ffsid session id out of a Set-Cookie value, or None."""
for part in (value or "").split(";"): for part in (value or "").split(";"):
@@ -318,218 +299,6 @@ def wiz(fc):
return body 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): def recommended(w):
"""The steps a GET /wiz body recommends: {id: reason}.""" """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)} 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")
+119
View File
@@ -3,6 +3,7 @@
# https://community.openglow.org # https://community.openglow.org
# SPDX-License-Identifier: MIT # SPDX-License-Identifier: MIT
import json
import os import os
import shutil import shutil
import subprocess import subprocess
@@ -120,6 +121,89 @@ class MachineNameTests(unittest.TestCase):
self.assertFalse(heading.startswith("#") or ".local" in issue, issue) 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): class NetworkBootTests(unittest.TestCase):
STANZA = ("auto wlan0\n" STANZA = ("auto wlan0\n"
"iface wlan0 inet dhcp\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/ssh/sshd_config_readonly", self.SSHD)
self.put("etc/network/interfaces", "auto lo\niface lo inet loopback\n" + NetworkBootTests.STANZA) self.put("etc/network/interfaces", "auto lo\niface lo inet loopback\n" + NetworkBootTests.STANZA)
self.put("etc/nsswitch.conf", "hosts: files dns\n") 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): def path(self, rel):
return os.path.join(self.root, rel) return os.path.join(self.root, rel)
@@ -313,6 +405,33 @@ class RootfsLintTests(unittest.TestCase):
os.makedirs(self.path("etc/avahi")) os.makedirs(self.path("etc/avahi"))
self.assertFails("mdns") 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__": if __name__ == "__main__":
unittest.main() unittest.main()
+3 -22
View File
@@ -3,9 +3,9 @@
# https://community.openglow.org # https://community.openglow.org
# SPDX-License-Identifier: MIT # SPDX-License-Identifier: MIT
"""The setup.* suite on the host: the registration (ids, kinds, the """The setup.* suite on the host: the registration (the operator tests'
takeover tests, the operator tests' hands), the record builders, the hands, the intake tests' precheck), the record builders, the cookie
cookie parsing, and the settle rule for a gated supervisor.""" parsing, and the settle rule for a gated supervisor."""
import json import json
import os import os
import shutil import shutil
@@ -18,19 +18,11 @@ import helpers # noqa: E402
from forgetest import baseline, catalog # noqa: E402 from forgetest import baseline, catalog # noqa: E402
from forgetest.suite import setup # noqa: E402 from forgetest.suite import setup # noqa: E402
IDS = ("setup.account-login",)
class RegistrationTests(unittest.TestCase): class RegistrationTests(unittest.TestCase):
def setUp(self): def setUp(self):
self.reg = catalog.load_suite() 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): def test_recommended_reads_the_status_document(self):
w = {"recommended": [{"id": "laser.floor", "reason": "the tube was replaced"}, w = {"recommended": [{"id": "laser.floor", "reason": "the tube was replaced"},
{"id": "laser.focus", "reason": "the tray 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"))) and t.kind != "auto" and not t.fixture_runnable(("button", "lid", "interlock")))
self.assertEqual(attended, ["setup.cloud-header-capture", "setup.focus"]) 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): class RecordTests(unittest.TestCase):
STATUS = {"sheet_id": "ABCDE-FGHIJ", STATUS = {"sheet_id": "ABCDE-FGHIJ",
@@ -4,5 +4,5 @@
# only SRCREV and PV here - the image manifest leaves *-pin.inc out of the # only SRCREV and PV here - the image manifest leaves *-pin.inc out of the
# layer content hash because the component entry already identifies the # layer content hash because the component entry already identifies the
# pinned source (forgefirm-image-manifest.bbclass). # pinned source (forgefirm-image-manifest.bbclass).
SRCREV = "22a2cfc04242ddc50ed0a54e967a59634f3f4d6d" SRCREV = "6c2bb389fa21ebefbde200e03e9b6487e9940369"
PV = "0.1.32" PV = "0.1.33"
@@ -4,5 +4,5 @@
# only SRCREV and PV here - the image manifest leaves *-pin.inc out of the # only SRCREV and PV here - the image manifest leaves *-pin.inc out of the
# layer content hash because the component entry already identifies the # layer content hash because the component entry already identifies the
# pinned source (forgefirm-image-manifest.bbclass). # pinned source (forgefirm-image-manifest.bbclass).
SRCREV = "31cdb4005668286c9faa5b4b834f0cbc3fdbbe9b" SRCREV = "bb75b23088b6ecce502ee3122bfb089b1c2641f1"
PV = "0.1.2" PV = "0.1.4"
@@ -29,6 +29,18 @@
# mdns no mDNS responder: no /etc/avahi, no avahi-daemon, no # mdns no mDNS responder: no /etc/avahi, no avahi-daemon, no
# libnss_mdns, and the hosts line of nsswitch.conf names no # libnss_mdns, and the hosts line of nsswitch.conf names no
# mdns source. # 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} root=${1:?rootfs}
kind=${2:?release or dev} 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" fail mdns "nsswitch.conf resolves hosts through mdns"
fi fi
# ------------------------------------------------------------ init-order
# link <runlevel dir> <S|K> <name>: 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 <what> <a> <b>: 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 exit $failed
+69 -1
View File
@@ -64,6 +64,10 @@ reported messages:
cancels it from the hold - reason reported, the reset banner, no cancels it from the hold - reason reported, the reset banner, no
alarm, the return to the job start - and a later press resumes alarm, the return to the job start - and a later press resumes
nothing 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 The disarm grace is shortened via a temp config (GFHOME_CONF), the
cooling verdict is published hermetically (GF_VERDICT_FILE), the same cooling verdict is published hermetically (GF_VERDICT_FILE), the same
@@ -214,9 +218,11 @@ class Session:
"""One controller process with the lifecycle overrides applied.""" """One controller process with the lifecycle overrides applied."""
def __init__(self, name, fire_ok=True, disarm_s=2, switches=None, conf_extra="", 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.name = name
self.workdir = tempfile.mkdtemp(prefix="laser-lifecycle-") 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") conf = os.path.join(self.workdir, "forgefirm.conf")
with open(conf, "w") as f: with open(conf, "w") as f:
# The fan grace at 0 keeps the arm's acknowledgment budget at its # 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") GF_STATE_DIR=self.workdir, FFLOG_STDERR="1")
env.pop("GFSINK", None) env.pop("GFSINK", None)
env.pop("GF_SWITCH_FILE", None) env.pop("GF_SWITCH_FILE", None)
env.pop("GFSINK_DUMP", None)
if self.dump:
env["GFSINK_DUMP"] = self.dump
self.switch_file = None self.switch_file = None
if switches is not None: if switches is not None:
self.switch_file = os.path.join(self.workdir, "switches") 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() 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(): def test_lid_cancel_from_hold():
"""Rule 16: a job paused on the button is not resumable past a lid """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 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_button_pause_resume()
test_lid_cancels_and_returns() test_lid_cancels_and_returns()
test_pause_then_lid_cancel_returns_to_the_job_start() test_pause_then_lid_cancel_returns_to_the_job_start()
test_cancel_and_abort()
test_lid_cancel_from_hold() test_lid_cancel_from_hold()
test_lid_policy_hold() test_lid_policy_hold()
test_sigterm_mid_job() test_sigterm_mid_job()
+11 -20
View File
@@ -241,18 +241,14 @@ else
fi fi
# Root policy gate. The release image ships root WITHOUT a password (the # Root policy gate. The release image ships root WITHOUT a password (the
# serial console is the recovery path) and sshd refuses root and empty # serial console is the recovery path): root's shadow field must be empty.
# passwords: PermitRootLogin no and PermitEmptyPasswords no must be # sshd's refusal of root and of empty passwords is the rootfs lint's
# active (uncommented) in the built sshd_config, and root's shadow field # (meta-forgefirm/recipes-forgefirm/images/rootfs-lint.sh), which fails
# must be empty. Read the built files, not the recipes: this catches a # the build. Read the built file, not the recipes: this catches a drift
# drift however it got in (recipe, local.conf, an inherited class). # however it got in (recipe, local.conf, an inherited class).
SSHD_CONFIG=$(debugfs -R "cat /etc/ssh/sshd_config" "$EXT4" 2>/dev/null) SSHD_CONFIG=$(debugfs -R "cat /etc/ssh/sshd_config" "$EXT4" 2>/dev/null)
[ -n "$SSHD_CONFIG" ] \ [ -n "$SSHD_CONFIG" ] \
|| die "release rootfs carries no /etc/ssh/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 \ ROOT_SHADOW=$(debugfs -R "cat /etc/shadow" "$EXT4" 2>/dev/null \
| awk -F: '$1=="root"{print; exit}') | awk -F: '$1=="root"{print; exit}')
[ -n "$ROOT_SHADOW" ] \ [ -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}') ROOT_PW=$(printf '%s\n' "$ROOT_SHADOW" | awk -F: '{print $2}')
[ -z "$ROOT_PW" ] \ [ -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" || 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 # Read-only rootfs gate. The release rootfs mounts read-only: the root
# line of the built fstab carries ro, the rcS default agrees (the # line of the built fstab carries ro, and sshd keeps its host keys on
# read-only-rootfs image feature), no factory-slot mount is in the # /data, where the read-only rootfs cannot hold them. rcS's
# release fstab (those belong to the dev image), and sshd keeps its host # ROOTFS_READ_ONLY and the fstab with no factory slot are the rootfs
# keys on /data, where the read-only rootfs cannot hold them. # lint's.
FSTAB=$(debugfs -R "cat /etc/fstab" "$EXT4" 2>/dev/null) FSTAB=$(debugfs -R "cat /etc/fstab" "$EXT4" 2>/dev/null)
[ -n "$FSTAB" ] \ [ -n "$FSTAB" ] \
|| die "release rootfs carries no /etc/fstab" || die "release rootfs carries no /etc/fstab"
printf '%s\n' "$FSTAB" | awk '$1 == "/dev/root" && $2 == "/" { print $4 }' \ printf '%s\n' "$FSTAB" | awk '$1 == "/dev/root" && $2 == "/" { print $4 }' \
| grep -Eq '(^|,)ro(,|$)' \ | grep -Eq '(^|,)ro(,|$)' \
|| die "release fstab does not mount / read-only (base-files fstab or read-only-rootfs drift?)" || 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 ] \ [ "$(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?)" || 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, # Config-level guard: debug-tweaks must not sit in the shared kas config,
# where it would apply to every target including the release image. # where it would apply to every target including the release image.