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.
This commit is contained in:
ScottW514
2026-10-15 14:02:20 -04:00
parent 56aa164708
commit c3da9fa1bd
11 changed files with 317 additions and 822 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"),
# 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
+3 -323
View File
@@ -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
+1 -104
View File
@@ -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)
+14 -42
View File
@@ -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/"
+1 -73
View File
@@ -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."],
+4 -235
View File
@@ -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")
+119
View File
@@ -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()
+3 -22
View File
@@ -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",
@@ -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 <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
+69 -1
View File
@@ -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()
+11 -20
View File
@@ -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.