From ca93c4262845feed7c247b8836625d00f7ab68ee Mon Sep 17 00:00:00 2001 From: DevomB Date: Fri, 23 Oct 2026 01:23:42 -0700 Subject: [PATCH 01/21] A touchscreen drives the pointer: a tap is a click, a drag a drag dwl 0.8 drops touch devices, so a laptop's touchscreen did nothing. dwl now attaches one to its cursor and turns the first finger into the pointer: down moves it there and presses the left button, moving drags, lifting releases. The press goes through dwl's own buttonpress, so a tap picks a window as a click does, under the same focus rules and the lock's. Zones are offered no touch, only those pointer events, so the proxy and its policy are unchanged. A touchscreen covers the built-in panel (eDP, LVDS or DSI) when there is one, and every screen otherwise. The desktop suite proves it on QEMU's virtio-multitouch: run-ovmf.sh's --touch adds one, bound to the GPU's first head; vm-drive.py's touch: step taps it through QMP; and gui-check's touch-picks-window has a tap give an unasked zone window the keyboard. vm-drive's fixture checks the step's events. --- build/guest-tests/gui-check.sh | 28 +++++++ docs/user-guide.md | 5 +- tools/desktop/dwl-zone-borders.py | 123 ++++++++++++++++++++++++++++++ tools/image/gui-test.sh | 5 +- tools/image/run-ovmf.sh | 17 ++++- tools/image/vm-drive.py | 23 ++++++ tools/tests/vm-drive.py | 24 ++++++ 7 files changed, 219 insertions(+), 6 deletions(-) diff --git a/build/guest-tests/gui-check.sh b/build/guest-tests/gui-check.sh index 77b566fc..239dea6c 100755 --- a/build/guest-tests/gui-check.sh +++ b/build/guest-tests/gui-check.sh @@ -21,6 +21,7 @@ # GT CONSENT-CODE 1 NN type NN and Enter (the question's code) # GT CONSENT-WAIT 2 type y and Enter (not the code: refused) # GT POINTER-ZONE0, GT POINTER-UNTRUSTED move the pointer onto the newest window +# GT TOUCH-UNTRUSTED tap the screen's left half (the newest window) # GT SCREENSHOT-POPUP take a screenshot # GT KEY-MENU-POPUP press Alt+p (the chrome menu) # GT KEY-FOCUS-POPUP press Alt+j @@ -1184,6 +1185,33 @@ if since_mark cursor untrusted | grep -q 'asked for the text cursor by name' && else fail "zone-cursor-by-name" "$(since_mark cursor untrusted | tail -4 | tr '\n' ' ')" fi +# --- a touchscreen drives the pointer ------------------------------------------------------- +# dwl takes a touchscreen as the pointer, so a tap is a click: it gives a window the keyboard as +# a click does, and zones are offered no touch. The VM's touchscreen is QEMU's virtio-multitouch +# (run-ovmf.sh --touch). untrusted's window maps beside zone 0's, unasked, where dwl puts the +# newest: the screen's left half, which the host taps. +stop_zone untrusted +as_user "/usr/libexec/kryptik/wlprobe cursor 40" > "$LOG/touch-zone0.out" 2>&1 & +touch0=$! +wait_for 20 grep -q committed "$LOG/touch-zone0.out" +wait_for 10 grep -qx 'zone=0' "$RT/kryptik/focus" +mark touch untrusted +launch_plain untrusted "/usr/libexec/kryptik/wlprobe cursor 30" > "$LOG/launch-touch.out" 2>&1 +wait_for 20 probe_committed touch +sleep 2 +touch_before="$(grep '^zone=' "$RT/kryptik/focus" 2>/dev/null)" +echo "GT TOUCH-UNTRUSTED" +if ! grep -q 'Name="QEMU Virtio MultiTouch"' /proc/bus/input/devices; then + fail "touch-picks-window" "no touchscreen in this guest: $(grep '^N: ' /proc/bus/input/devices | tr '\n' ' ')" +elif [[ "$touch_before" != "zone=0" ]]; then + fail "touch-picks-window" "zone 0's window did not have the keyboard before the tap: ${touch_before:-no focus record}" +elif wait_for 10 grep -qx 'zone=untrusted' "$RT/kryptik/focus"; then + pass "touch-picks-window" "a tap on untrusted's window gave it the keyboard, as a click does" +else + fail "touch-picks-window" "after the tap: $(tr '\n' ' ' < "$RT/kryptik/focus" 2>/dev/null); $(since_mark touch untrusted | tail -2 | tr '\n' ' ')" +fi +pkill -u "$USER_NAME" -f 'wlprobe cursor 40' 2>/dev/null; wait "$touch0" 2>/dev/null + # --- a zone's window hears the scale it is shown at ------------------------------------------ # wp_fractional_scale_v1 through the proxy: the compositor tells each window its output's scale, # in 120ths, so a browser draws text sharp at 1.5 instead of being scaled down from 2. This diff --git a/docs/user-guide.md b/docs/user-guide.md index 1ab04fe9..32171738 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -287,7 +287,10 @@ The menu shows the zone's glyph and label and the window's title, which the zone's proxy prefixes with the zone's name, as `[untrusted] ...`. A zone's window that opens while another window has the keyboard does not take it, nor does it get it when that window closes: Alt+j, Alt+k, a click or the -pointer moving onto it moves the keyboard there. Keys (Alt is the modifier): +pointer moving onto it moves the keyboard there. A touchscreen works as the +pointer, on the built-in screen when there is one: a tap is a click and a +drag drags with one finger, and zones are given pointer events only, not +touch. Keys (Alt is the modifier): | keys | what | | --- | --- | diff --git a/tools/desktop/dwl-zone-borders.py b/tools/desktop/dwl-zone-borders.py index c952592e..c64d97b2 100755 --- a/tools/desktop/dwl-zone-borders.py +++ b/tools/desktop/dwl-zone-borders.py @@ -1271,6 +1271,129 @@ \tif (lidshut && undocktimer) \t\twl_event_source_timer_update(undocktimer, 5000); } +"""), + # A touchscreen drives the pointer, where dwl 0.8 drops touch devices. A finger down moves + # the pointer there and presses its left button, moving drags, and lifting releases it: a tap + # clicks through dwl's own pointer path, its focus rules and the lock's. Zones are offered no + # touch, only those pointer events, so the proxy and its policy stay as they are. + ("""#include +""", + """#include +#include +"""), + ("""static struct wl_listener cursor_motion_absolute = {.notify = motionabsolute}; +""", + """static struct wl_listener cursor_motion_absolute = {.notify = motionabsolute}; +static void touchdown(struct wl_listener *listener, void *data); +static void touchmotion(struct wl_listener *listener, void *data); +static void touchup(struct wl_listener *listener, void *data); +static void touchcancel(struct wl_listener *listener, void *data); +static struct wl_listener cursor_touch_down = {.notify = touchdown}; +static struct wl_listener cursor_touch_motion = {.notify = touchmotion}; +static struct wl_listener cursor_touch_up = {.notify = touchup}; +static struct wl_listener cursor_touch_cancel = {.notify = touchcancel}; +static struct wl_listener cursor_touch_frame = {.notify = cursorframe}; +"""), + ("""\twl_signal_add(&cursor->events.frame, &cursor_frame); +""", + """\twl_signal_add(&cursor->events.frame, &cursor_frame); +\twl_signal_add(&cursor->events.touch_down, &cursor_touch_down); +\twl_signal_add(&cursor->events.touch_motion, &cursor_touch_motion); +\twl_signal_add(&cursor->events.touch_up, &cursor_touch_up); +\twl_signal_add(&cursor->events.touch_cancel, &cursor_touch_cancel); +\twl_signal_add(&cursor->events.touch_frame, &cursor_touch_frame); +"""), + ("""\tcase WLR_INPUT_DEVICE_SWITCH: +\t\tcreateswitch(wlr_switch_from_input_device(device)); +\t\tbreak; +""", + """\tcase WLR_INPUT_DEVICE_SWITCH: +\t\tcreateswitch(wlr_switch_from_input_device(device)); +\t\tbreak; +\tcase WLR_INPUT_DEVICE_TOUCH: +\t\twlr_cursor_attach_input_device(cursor, device); +\t\tbreak; +"""), + ("""void +cursorframe(struct wl_listener *listener, void *data) +{ +""", + """/* Kryptik: the finger that drives the pointer, -1 while none is down; a second is ignored. */ +static int32_t touchid = -1; + +/* Kryptik: the pointer to where a touch is: on the built-in panel when there is one, as a + * laptop's touchscreen is its own, and over every screen otherwise. */ +static void +touchpoint(struct wlr_input_device *device, uint32_t time, double x, double y) +{ +\tstruct wlr_box box = {0}; +\tdouble lx, ly; +\tMonitor *m; + +\twl_list_for_each(m, &mons, link) { +\t\tconst char *n = m->wlr_output->name; +\t\tif (m->wlr_output->enabled && (!strncmp(n, "eDP", 3) || !strncmp(n, "LVDS", 4) || !strncmp(n, "DSI", 3))) +\t\t\tbox = m->m; +\t} +\tif (!wlr_box_empty(&box)) { +\t\tlx = box.x + x * box.width; +\t\tly = box.y + y * box.height; +\t} else { +\t\twlr_cursor_absolute_to_layout_coords(cursor, device, x, y, &lx, &ly); +\t} +\tmotionnotify(time, device, lx - cursor->x, ly - cursor->y, lx - cursor->x, ly - cursor->y); +} + +/* Kryptik: the left button, as buttonpress takes it from a pointer. */ +static void +touchbutton(uint32_t time, enum wl_pointer_button_state state) +{ +\tstruct wlr_pointer_button_event event = {.time_msec = time, .button = BTN_LEFT, .state = state}; +\tbuttonpress(NULL, &event); +} + +void +touchdown(struct wl_listener *listener, void *data) +{ +\tstruct wlr_touch_down_event *event = data; +\tif (touchid != -1) +\t\treturn; +\ttouchid = event->touch_id; +\ttouchpoint(&event->touch->base, event->time_msec, event->x, event->y); +\ttouchbutton(event->time_msec, WL_POINTER_BUTTON_STATE_PRESSED); +} + +void +touchmotion(struct wl_listener *listener, void *data) +{ +\tstruct wlr_touch_motion_event *event = data; +\tif (event->touch_id == touchid) +\t\ttouchpoint(&event->touch->base, event->time_msec, event->x, event->y); +} + +void +touchup(struct wl_listener *listener, void *data) +{ +\tstruct wlr_touch_up_event *event = data; +\tif (event->touch_id != touchid) +\t\treturn; +\ttouchid = -1; +\ttouchbutton(event->time_msec, WL_POINTER_BUTTON_STATE_RELEASED); +} + +void +touchcancel(struct wl_listener *listener, void *data) +{ +\tstruct wlr_touch_cancel_event *event = data; +\tif (event->touch_id != touchid) +\t\treturn; +\ttouchid = -1; +\ttouchbutton(event->time_msec, WL_POINTER_BUTTON_STATE_RELEASED); +} + +void +cursorframe(struct wl_listener *listener, void *data) +{ """), # Locked, dwl heeds no key at all, so a laptop that starts to play aloud under the lock # is silenced only by unlocking. It heeds config.h's lockedkeys instead: the volume and diff --git a/tools/image/gui-test.sh b/tools/image/gui-test.sh index 12134e0b..9f25aa0a 100755 --- a/tools/image/gui-test.sh +++ b/tools/image/gui-test.sh @@ -70,7 +70,7 @@ install_disk gui-install "$USB" --vars clean && green "installed" || { red "inst # ----------------------------------------------------------------- step 2 -- step "step 2: the desktop, driven" rm -f "$SHOT" "$SHOT_FS" "$SHOT_OVER" "$SHOT_HEAD" "$SHOT_POPUP" "$SHOT_LOCKED" -start_vm gui-p2 --net user --gpu --second-head --mem 3072 +start_vm gui-p2 --net user --gpu --second-head --touch --mem 3072 DRIVE_TIMEOUT=600 drive --qmp "$QMP" \ "expect:KRYPTIK_SMOKE: END" "seen:kryptik-firstboot: created user '${TUSER}'" "login:${TUSER}:${TPASS}" \ "send:su - root -c 'bash /usr/lib/kryptik/guest-tests/gui-check.sh ${TUSER} ${TPASS} 2>&1 | tee /var/log/kryptik/gui-check.log; echo GCHECK-DONE'" \ @@ -116,6 +116,7 @@ DRIVE_TIMEOUT=600 drive --qmp "$QMP" \ "expect:GT CONSENT-WAIT 2" "key:y" "key:ret" \ "expect:GT POINTER-ZONE0" "pointer:-4000,-4000" "pointer:120,120" \ "expect:GT POINTER-UNTRUSTED" "pointer:-4000,-4000" "pointer:120,120" \ + "expect:GT TOUCH-UNTRUSTED" "touch:250,500" \ "expect:GT SCREENSHOT-POPUP" "sleep:2" "screendump:${SHOT_POPUP}" \ "expect:GT KEY-MENU-POPUP" "key:alt+p" \ "expect:GT KEY-FOCUS-POPUP" "key:alt+j" \ @@ -183,7 +184,7 @@ for name in session-socket compositor-running desktop-memory-bound chrome-focus- second-head-scale-chosen zone-sees-heads-unnamed \ vt-admin-login vt-desktop-back vt-desktop-takes-keys dwl-execs-nothing dwl-without-group seat-is-dwls dwl-confined-to-its-files \ volume-key lock-passes-volume-keys lock-holds-other-keys \ - zone0-cursor-set zone0-cursor-shown zone-cursor-asked zone-hears-of-outputs zone-cursor-not-shown zone-cursor-by-name zone-fractional-scale compositor-socket-unreached \ + zone0-cursor-set zone0-cursor-shown zone-cursor-asked zone-hears-of-outputs zone-cursor-not-shown zone-cursor-by-name touch-picks-window zone-fractional-scale compositor-socket-unreached \ zone0-frame-clock zone-window-animates \ status-network-line-user \ zone0-fullscreen-granted zone0-fullscreen-needs-focus fullscreen-by-key marks-fullscreen zone-child-mapped child-focused parent-focused parent-fullscreen fullscreen-keeps-focus keyboard-stays-on-fullscreen zoom-keeps-keyboard zoom-twice-keeps-keyboard zone0-over-fullscreen-gets-keyboard zone0-window-ends-fullscreen child-ends-fullscreen; do diff --git a/tools/image/run-ovmf.sh b/tools/image/run-ovmf.sh index 263a1500..c3d54bc1 100755 --- a/tools/image/run-ovmf.sh +++ b/tools/image/run-ovmf.sh @@ -6,7 +6,7 @@ # [--disk FILE]... [--testctl FILE] [--vars clean|enrolled|ms|FILE] # [--vars-file FILE] [--mode console|smoke|serve] [--timeout N] # [--log FILE] [--net none|user] [--mem MB] [--cpus N] [--gpu] -# [--second-head] [--s3] [--sound WAV] [--usb-port] [--allow-reboot] +# [--second-head] [--touch] [--s3] [--sound WAV] [--usb-port] [--allow-reboot] # [--until REGEX] [--name TAG] # # --usb IMG the medium as a USB mass-storage device (removable), @@ -38,6 +38,8 @@ # --second-head with --gpu in serve mode: a second output on the GPU, off # until a VNC client on the printed socket asks for a size # (vm-drive.py's head: step), which plugs a monitor in +# --touch with --gpu: a touchscreen over the first output, QEMU's +# virtio-multitouch (8.1 on), which vm-drive.py's touch: step taps # --s3 offer suspend to RAM, which QEMU otherwise hides: the guest # sleeps until vm-drive.py's wakeup step # --sound WAV two HD Audio cards: the first only plays, into the file WAV @@ -54,7 +56,7 @@ SELF="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "${SELF}/../../build/lib/common.sh" USB=""; ISO=""; NOMEDIA=0; DISKS=(); TESTCTL=""; VARS="clean"; VARS_FILE="" -MODE="smoke"; TIMEOUT=300; LOG=""; NET="none"; MEM=2048; CPUS=2; GPU=0; HEAD2=0; S3=0; SOUND=""; ALLOW_REBOOT=0; NAME="vm" +MODE="smoke"; TIMEOUT=300; LOG=""; NET="none"; MEM=2048; CPUS=2; GPU=0; HEAD2=0; TOUCH=0; S3=0; SOUND=""; ALLOW_REBOOT=0; NAME="vm" DISK_RO=0; DISK_4K=0; BLKDEBUG=""; UNTIL=""; USB_PORT=0; USB_RO=on while [[ "$#" -gt 0 ]]; do case "$1" in @@ -77,13 +79,14 @@ while [[ "$#" -gt 0 ]]; do --cpus) CPUS="${2:?}"; shift 2 ;; --gpu) GPU=1; shift ;; --second-head) HEAD2=1; shift ;; + --touch) TOUCH=1; shift ;; --s3) S3=1; shift ;; --sound) SOUND="${2:?}"; shift 2 ;; --usb-port) USB_PORT=1; shift ;; --allow-reboot) ALLOW_REBOOT=1; shift ;; --until) UNTIL="${2:?}"; shift 2 ;; --name) NAME="${2:?}"; shift 2 ;; - -h|--help) sed -n '2,49p' "${BASH_SOURCE[0]}"; exit 0 ;; + -h|--help) sed -n '2,51p' "${BASH_SOURCE[0]}"; exit 0 ;; *) die "unknown argument: $1" ;; esac done @@ -93,6 +96,7 @@ done [[ "$NOMEDIA" -eq 1 && "${#DISKS[@]}" -eq 0 ]] && die "--no-media needs at least one --disk" [[ -n "$UNTIL" && "$MODE" != smoke ]] && die "--until is for --mode smoke" [[ "$HEAD2" -eq 1 && ( "$GPU" -ne 1 || "$MODE" != serve ) ]] && die "--second-head is for --gpu in --mode serve" +[[ "$TOUCH" -eq 1 && "$GPU" -ne 1 ]] && die "--touch is for --gpu: the touchscreen covers its first output" [[ "$HEAD2" -eq 1 && "$S3" -eq 1 ]] && die "--second-head needs virtio-gpu, which --s3 gives up for std VGA's framebuffer" # Files only. This hands paths to a process that writes to them. @@ -171,6 +175,13 @@ elif [[ "$GPU" -eq 1 ]]; then ARGS+=( -display none -vga none -device virtio-vga,id=gpu0 ) fi ARGS+=( -device virtio-keyboard-pci -device virtio-mouse-pci ) + if [[ "$TOUCH" -eq 1 ]]; then + "$QEMU" -device help 2>/dev/null | grep -q '"virtio-multitouch-pci"' \ + || die "--touch: this QEMU has no virtio-multitouch-pci (QEMU 8.1 or later has)" + # Bound to the GPU's first head, so an input-send-event naming that head reaches it + # before the mouse, which takes button events too. + ARGS+=( -device virtio-multitouch-pci,id=touch0,display=gpu0,head=0 ) + fi else ARGS+=( -display none -vga none ) fi diff --git a/tools/image/vm-drive.py b/tools/image/vm-drive.py index a199dea0..3820b2de 100755 --- a/tools/image/vm-drive.py +++ b/tools/image/vm-drive.py @@ -32,6 +32,9 @@ pointer:DX,DY move the guest's mouse by DX,DY through QMP; it is relative and accelerated, so a large move ends at the screen's edge + touch:X,Y tap the touchscreen (run-ovmf.sh --touch) at X,Y, + thousandths of the first output's width and height: + one finger down, then lifted type-from:REGEX wait for REGEX on a line that has ended (REGEX names no line end), then type what its first group matched (digits and letters) and Enter @@ -258,6 +261,15 @@ def vnc_size(s, w, h): if n: msg += struct.pack(">IHHHHI", 0, 0, 0, w, h, 0) s.sendall(msg) +def touch_events(x, y): + """A tap at X,Y (0..0x7fff) as two input-send-event batches: the finger down, then lifted.""" + def mtt(kind, tracking, axis="x", value=0): + return {"type": "mtt", "data": {"type": kind, "slot": 0, "tracking-id": tracking, "axis": axis, "value": value}} + down = [mtt("begin", 1), mtt("data", 1, "x", x), mtt("data", 1, "y", y), + {"type": "btn", "data": {"down": True, "button": "touch"}}] + up = [mtt("end", -1), {"type": "btn", "data": {"down": False, "button": "touch"}}] + return [down, up] + def qmp(path, cmd, args=None): s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) s.connect(path) @@ -352,6 +364,17 @@ def main(): r = qmp(qmpsock, "input-send-event", {"events": events}) if "error" in r: raise RuntimeError(f"input-send-event {rest}: {r['error']}") time.sleep(0.3) + elif kind == "touch": + if not qmpsock: raise RuntimeError("touch needs --qmp") + px, py = (int(v) for v in rest.split(",")) + if not (0 <= px <= 1000 and 0 <= py <= 1000): raise RuntimeError(f"touch:{rest}: not in 0..1000") + tap = touch_events(px * 0x7fff // 1000, py * 0x7fff // 1000) + for events in tap: + # The head binds the events to run-ovmf.sh's touchscreen, not the mouse. + r = qmp(qmpsock, "input-send-event", {"device": "gpu0", "head": 0, "events": events}) + if "error" in r: raise RuntimeError(f"input-send-event touch:{rest}: {r['error']}") + time.sleep(0.15) + time.sleep(0.3) elif kind == "type-from": if not qmpsock: raise RuntimeError("type-from needs --qmp") text = d.line_value(rest) diff --git a/tools/tests/vm-drive.py b/tools/tests/vm-drive.py index 110441f1..eccdce64 100755 --- a/tools/tests/vm-drive.py +++ b/tools/tests/vm-drive.py @@ -188,6 +188,30 @@ def run(self): ("device_add", {"driver": "usb-audio", "id": "usbsound", "audiodev": "snd2", "bus": "xhci.0"}), ("device_del", {"id": "usbsound"})]) +print("-- touch: taps the touchscreen on the GPU's first head: a finger down, then lifted") +sock7 = os.path.join(T, "serial7"); qmp7 = os.path.join(T, "qmp7") +Guest(sock7, {}).start() +q7 = Qmp(qmp7, busy=0); q7.start() +argv, sys.argv = sys.argv, ["vm-drive.py", "--serial", sock7, "--qmp", qmp7, "--timeout", "5", "touch:250,500"] +with contextlib.redirect_stdout(io.StringIO()): + rc = vm.main() +sys.argv = argv +check("touch: succeeds", rc, 0) +mt = lambda kind, tracking, axis="x", value=0: {"type": "mtt", "data": {"type": kind, "slot": 0, "tracking-id": tracking, "axis": axis, "value": value}} +check("the finger goes down at a quarter across and half down, then lifts, both bound to head 0", q7.asked, [ + ("input-send-event", {"device": "gpu0", "head": 0, "events": [ + mt("begin", 1), mt("data", 1, "x", 8191), mt("data", 1, "y", 16383), + {"type": "btn", "data": {"down": True, "button": "touch"}}]}), + ("input-send-event", {"device": "gpu0", "head": 0, "events": [ + mt("end", -1), {"type": "btn", "data": {"down": False, "button": "touch"}}]})]) +sock8 = os.path.join(T, "serial8") +Guest(sock8, {}).start() +argv, sys.argv = sys.argv, ["vm-drive.py", "--serial", sock8, "--qmp", qmp7, "--timeout", "5", "touch:1200,500"] +with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()): + rc = vm.main() +sys.argv = argv +check("a tap off the screen is refused before QEMU is asked", (rc != 0, len(q7.asked)), (True, 2)) + print() print(f"{PASS} passed, {FAIL} failed") sys.exit(0 if FAIL == 0 else 1) From db8f04f32429b7c8e213890ecf985ae6335f408a Mon Sep 17 00:00:00 2001 From: DevomB Date: Fri, 23 Oct 2026 01:33:56 -0700 Subject: [PATCH 02/21] The hardware report says what woke a sleep, which TPM and clock driver run Three things a first boot on a laptop needs and the report did not say: - Why a sleep was short. The laptop section now gives the interrupt that ended the last sleep, where the kernel keeps it (/sys/power/ pm_wakeup_irq, under PM_SLEEP_DEBUG), and the five wakeup sources that fired most. Where there is an IVRS table it also says whether the firmware gives Pluton's device (MSFT0201) and has pre-boot DMA protection on (IVinfo bit 1). AMD's family 1Ah processors need both to sleep with the IOMMU on, which amd-debug-tools checks, and a firmware setting can take either away. - Which TPM came up. The firmware line names the TPM's driver and the hardware random number generator the kernel reads, as some firmware TPMs register none. - How the clock is set. A cpufreq line gives the driver, the governor, the energy hint and amd-pstate's mode: power.sh's hints act only in a driver's active mode, and a firmware without CPPC leaves amd-pstate out. The fixture test checks each line, an empty wakeup interrupt, no AMD line without an IVRS table, and both IVRS answers. --- tools/install/kryptik-hwreport.sh | 35 +++++++++++++++++++++++++++++-- tools/tests/hwreport.sh | 30 +++++++++++++++++++++++++- 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/tools/install/kryptik-hwreport.sh b/tools/install/kryptik-hwreport.sh index 492d4403..9bdf6f85 100755 --- a/tools/install/kryptik-hwreport.sh +++ b/tools/install/kryptik-hwreport.sh @@ -94,9 +94,16 @@ firmware() { case "$(efi_byte SecureBoot)" in 1) echo "secure boot: on" ;; 0) echo "secure boot: off" ;; *) echo "secure boot: unreadable" ;; esac printf 'setup mode: %s\n' "$(efi_byte SetupMode)" printf 'lockdown: %s\n' "$(sed -n 's/.*\[\(.*\)\].*/\1/p' "$SYS/kernel/security/lockdown" 2>/dev/null)" + # The TPM's version and the driver it came up under, and the hardware random number generator + # the kernel reads, if any: some firmware TPMs register none. tpm=none - for t in "$SYS"/class/tpm/tpm*; do [ -e "$t" ] && tpm="version $(one "$t/tpm_version_major")"; done - printf 'tpm: %s\n' "$tpm" + for t in "$SYS"/class/tpm/tpm*; do + [ -e "$t" ] || continue + drv="$(target "$t/device/driver")" + tpm="version $(one "$t/tpm_version_major"), driver ${drv:--}" + done + rng="$(one "$SYS/class/misc/hw_random/rng_current")" + printf 'tpm: %s; hwrng: %s\n' "$tpm" "${rng:-none}" # What confines a device's DMA: the IOMMUs in use, their groups by how they translate (DMA # strict, DMA-FQ lazy, identity not at all), and what Thunderbolt lets a plugged device do. units="" @@ -148,6 +155,12 @@ cpu() { }' "$R/proc/cpuinfo" 2>/dev/null printf 'present: %s, online: %s, smt: %s\n' "$(one "$SYS/devices/system/cpu/present")" \ "$(one "$SYS/devices/system/cpu/online")" "$(one "$SYS/devices/system/cpu/smt/control")" + # Which driver sets the clock, and how: power.sh's energy hints act only in a driver's active + # mode, and a firmware without CPPC leaves amd-pstate out. + pol="$SYS/devices/system/cpu/cpufreq/policy0" + fd="$(one "$pol/scaling_driver")"; fg="$(one "$pol/scaling_governor")" + fe="$(one "$pol/energy_performance_preference")"; fa="$(one "$SYS/devices/system/cpu/amd_pstate/status")" + printf 'cpufreq: %s, governor %s, epp %s, amd-pstate %s\n' "${fd:-none}" "${fg:--}" "${fe:--}" "${fa:--}" kmsg | sed -n 's/^\[[^]]*\] *\(microcode: .*\)/\1/p' | head -n 3 for f in "$SYS"/devices/system/cpu/vulnerabilities/*; do [ -r "$f" ] && printf '%s: %s\n' "${f##*/}" "$(one "$f")" @@ -427,6 +440,24 @@ laptop() { done done printf 'power controller: %s\n' "${pmc:-none}" + # What ended the last sleep: the interrupt, where the kernel keeps it (PM_SLEEP_DEBUG), and the + # wakeup sources that fired most since boot. + irq="$(one "$SYS/power/pm_wakeup_irq")" + printf 'woken by irq: %s\n' "${irq:--}" + for w in "$SYS"/class/wakeup/wakeup*; do + wn="$(int "$w/wakeup_count")" + [ "${wn:-0}" -gt 0 ] && printf '%s %s\n' "$wn" "$(one "$w/name")" + done | sort -rn | head -n 5 | while read -r wn ws; do printf 'wakeup source %s wakeup_count %s\n' "$ws" "$wn"; done + # With the IOMMU on, AMD's family 1Ah processors sleep only where the firmware gives Pluton's + # device (MSFT0201) and has pre-boot DMA protection on (IVRS IVinfo bit 1), as amd-debug-tools + # checks: a firmware setting can take either away. + if [ -r "$SYS/firmware/acpi/tables/IVRS" ]; then + msft=absent + for a in "$SYS"/bus/acpi/devices/MSFT0201:*; do [ -e "$a" ] && msft=present; done + iv="$(od -An -tu4 -j36 -N4 "$SYS/firmware/acpi/tables/IVRS" 2>/dev/null | tr -d ' ')" + case "$iv" in ''|*[!0-9]*) dp=- ;; *) dp=off; [ $(( iv & 2 )) -eq 0 ] || dp=on ;; esac + printf 'amd iommu: msft0201 %s, pre-boot dma protection %s\n' "$msft" "$dp" + fi # Intel's image processing units by PCI ID: the IPU6 and IPU7 ones 6.18's drivers bind (ipu6.h, # staging ipu7.h), and IPU8's, which staging ipu7 names but does not bind yet. for d in "$SYS"/bus/pci/devices/*; do diff --git a/tools/tests/hwreport.sh b/tools/tests/hwreport.sh index 6e0a8d33..52f176b4 100755 --- a/tools/tests/hwreport.sh +++ b/tools/tests/hwreport.sh @@ -40,6 +40,12 @@ printf '\006\000\000\000\001' > "$W/sys/firmware/efi/efivars/SecureBoot-8be4df61 printf '\006\000\000\000\000' > "$W/sys/firmware/efi/efivars/SetupMode-8be4df61-93ca-11d2-aa0d-00e098032b8c" put sys/kernel/security/lockdown 'none integrity [confidentiality]' put sys/class/tpm/tpm0/tpm_version_major 2 +mkdir -p "$W/sys/class/tpm/tpm0/device" "$W/sys/bus/platform/drivers/tpm_tis" +ln -s "$W/sys/bus/platform/drivers/tpm_tis" "$W/sys/class/tpm/tpm0/device/driver" +put sys/class/misc/hw_random/rng_current tpm-rng-0 +put sys/devices/system/cpu/cpufreq/policy0/scaling_driver intel_pstate +put sys/devices/system/cpu/cpufreq/policy0/scaling_governor powersave +put sys/devices/system/cpu/cpufreq/policy0/energy_performance_preference balance_power for n in 0 1; do printf 'processor\t: %s\nvendor_id\t: GenuineIntel\ncpu family\t: 6\nmodel\t\t: 140\nmodel name\t: 11th Gen Intel(R) Core(TM) i7-1165G7 @ 2.80GHz\nstepping\t: 1\nmicrocode\t: 0xa4\nflags\t\t: fpu nx smep smap umip ibt rdrand aes\n\n' "$n" done > "$W/proc/cpuinfo" @@ -151,7 +157,8 @@ want "whether its state is kept" '^state: tmpfs$' want "the maker and the model" '^product_version: ThinkPad X1 Carbon Gen 9$' want "Secure Boot, read from the firmware" '^secure boot: on$' want "the lockdown in force" '^lockdown: confidentiality$' -want "the TPM" '^tpm: version 2$' +want "the TPM, its driver and the random number generator" '^tpm: version 2, driver tpm_tis; hwrng: tpm-rng-0$' +want "the clock's driver, governor and energy hint" '^cpufreq: intel_pstate, governor powersave, epp balance_power, amd-pstate -$' want "the processor" '^vendor, family/model/stepping: GenuineIntel, 6/140/1$' want "its microcode, and what loaded it" '^microcode: 0xa4$' want "the loader's own line" '^microcode: Current revision: 0x000000a4$' @@ -443,6 +450,27 @@ lap "its sensor, by its ACPI ID" '^camera acpi OVTI01A0:00 sensor$' lap "the sensor's power controller" '^camera acpi INT3472:01 sensor power$' lap "and the Visual Sensing Controller" '^camera acpi INTC1009:00 visual sensing controller$' nolap "not a sensor the firmware calls absent" 'OVTI02C1' +lap "no wakeup interrupt where the kernel keeps none" '^woken by irq: -$' +nolap "and no AMD IOMMU line without an IVRS table" '^amd iommu: ' + +echo "-- what ended a sleep, and AMD's sleep needs with the IOMMU on" +put sys/power/pm_wakeup_irq 9 +put sys/class/wakeup/wakeup0/name PNP0C0D:00 +put sys/class/wakeup/wakeup0/wakeup_count 2 +put sys/class/wakeup/wakeup1/name alarmtimer.0.auto +put sys/class/wakeup/wakeup1/wakeup_count 0 +mkdir -p "$W/sys/firmware/acpi/tables" +{ head -c 36 /dev/zero; printf '\002\000\000\000'; } > "$W/sys/firmware/acpi/tables/IVRS" +put sys/bus/acpi/devices/MSFT0201:00/status 15 +laptop_now +lap "the interrupt that ended the last sleep" '^woken by irq: 9$' +lap "a wakeup source that fired, and how often" '^wakeup source PNP0C0D:00 wakeup_count 2$' +nolap "not one that never did" '^wakeup source alarmtimer' +lap "Pluton's device and pre-boot DMA protection, read from the firmware" '^amd iommu: msft0201 present, pre-boot dma protection on$' +{ head -c 36 /dev/zero; printf '\000\000\000\000'; } > "$W/sys/firmware/acpi/tables/IVRS" +rm -rf "$W/sys/bus/acpi/devices/MSFT0201:00" +laptop_now +lap "either taken away by a firmware setting" '^amd iommu: msft0201 absent, pre-boot dma protection off$' echo echo "${PASS} passed, ${FAIL} failed" From 0463118ac78a7bde1c3efb2982217d8aff053615 Mon Sep 17 00:00:00 2001 From: DevomB Date: Fri, 23 Oct 2026 01:37:15 -0700 Subject: [PATCH 03/21] The guide says how a laptop gets its country's Wi-Fi rules, and which firmware settings stay on Nothing in Kryptik sets a regulatory country, so a radio keeps the world's rules (country 00 in wireless-regdb 2026.09.03) until it joins a network that names its country, and goes back to them at every disconnect: no 6 GHz at all, and 5 GHz listen-only. The Wi-Fi section now says so and how to set the country until the next boot (`iw reg set XX` as root at the administration login), and that a Wi-Fi 7 network offering only SAE-EXT-KEY is not joined, as the saved networks offer WPA-PSK, PSK-SHA256 and SAE. The AMD paragraph now asks for the firmware's IOMMU, its DMA protection and Pluton to stay on: without the IOMMU every device reaches all of memory, and the Ryzen AI 300 parts sleep with the IOMMU on only where the firmware keeps Pluton's device and pre-boot DMA protection. --- docs/user-guide.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/docs/user-guide.md b/docs/user-guide.md index 1ab04fe9..70eb4360 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -113,6 +113,12 @@ older EPYC models: they keep the firmware's own microcode, the kernel log says `No sha256 digest for patch ID`, and every login prompt says to update the firmware. +Leave the firmware's IOMMU, its DMA protection and Pluton on. +- Without the IOMMU, every device can reach all of memory, and nothing at + the login prompt says so. +- AMD's Ryzen AI 300 processors sleep with the IOMMU on only where the + firmware keeps Pluton's device and its pre-boot DMA protection. + A firmware that also keeps Microsoft's certificates in `db` boots whatever Microsoft signs, so someone with the machine in hand can start such a system and have it ask for the state passphrase as Kryptik does. Taking Microsoft's @@ -440,6 +446,22 @@ network. A new passphrase keeps it so; forget the network to stop. `kryptik wifi list` names the saved networks, and `kryptik wifi forget SSID` removes one. +**Your country's radio rules.** Nothing sets the country the radio's rules +come from, so until it joins a network that names one, the radio keeps the +world's rules: +- the 6 GHz band is off, so a network on 6 GHz alone is never seen; +- on 5 GHz it only listens, and joins a network there once it has heard it. + +The rules go back to the world's at every disconnect. For your own country's +rules until the next boot: +1. At the administration login, run `su`. +2. Run `iw reg set XX`, where `XX` is your country's two letters (`US`, + `DE`). +3. `iw reg get` shows the rules in force. + +A Wi-Fi 7 network that offers only SAE-EXT-KEY, its multi-link security, is +not joined. Have the router offer WPA3 (SAE) or WPA2 beside it. + **The local network.** Every zone with a network reaches the internet through the net zone. The network the machine itself is on, with its router, its printers and the page a hotel's or café's Wi-Fi asks you to log in on, is From cf07926ff84d57b3f8d329140d0f432a49f4675d Mon Sep 17 00:00:00 2001 From: DevomB Date: Fri, 23 Oct 2026 04:38:16 -0700 Subject: [PATCH 04/21] The report ranks wakeup sources by their events, and says what amd-debug-tools asks for From 93's read: - A wakeup source's wakeup_count grows only while events_check_enabled is set (drivers/base/power/wakeup.c), which only a write to /sys/power/wakeup_count turns on, and kryptikd sleeps by writing "mem" to /sys/power/state alone. So every count stayed 0 and the lines never printed. The five that fired most now come by event_count, as the comment said they did, and the fixture's wakeup_count is the 0 a Kryptik machine shows. - The IVRS line's comment said more than amd-debug-tools does: its rule is for family 1Ah models 0x20-0x2e, 0x60-0x6e and 0x70-0x7e, it takes MSFT0201 named in the IVRS in place of pre-boot DMA protection, and it calls MSFT0201 an ACPI device, nothing more. The comment now says that, and the line gains "msft0201 in ivrs yes|no", so protection off is not read as a laptop that cannot sleep. --- tools/install/kryptik-hwreport.sh | 16 +++++++++------- tools/tests/hwreport.sh | 15 +++++++++------ 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/tools/install/kryptik-hwreport.sh b/tools/install/kryptik-hwreport.sh index 9bdf6f85..6b41440d 100755 --- a/tools/install/kryptik-hwreport.sh +++ b/tools/install/kryptik-hwreport.sh @@ -441,22 +441,24 @@ laptop() { done printf 'power controller: %s\n' "${pmc:-none}" # What ended the last sleep: the interrupt, where the kernel keeps it (PM_SLEEP_DEBUG), and the - # wakeup sources that fired most since boot. + # wakeup sources that fired most since boot. By their events: kryptikd sleeps without the + # wakeup_count handshake, so each source's wakeup_count stays 0. irq="$(one "$SYS/power/pm_wakeup_irq")" printf 'woken by irq: %s\n' "${irq:--}" for w in "$SYS"/class/wakeup/wakeup*; do - wn="$(int "$w/wakeup_count")" + wn="$(int "$w/event_count")" [ "${wn:-0}" -gt 0 ] && printf '%s %s\n' "$wn" "$(one "$w/name")" - done | sort -rn | head -n 5 | while read -r wn ws; do printf 'wakeup source %s wakeup_count %s\n' "$ws" "$wn"; done - # With the IOMMU on, AMD's family 1Ah processors sleep only where the firmware gives Pluton's - # device (MSFT0201) and has pre-boot DMA protection on (IVRS IVinfo bit 1), as amd-debug-tools - # checks: a firmware setting can take either away. + done | sort -rn | head -n 5 | while read -r wn ws; do printf 'wakeup source %s events %s\n' "$ws" "$wn"; done + # With the IOMMU on, amd-debug-tools holds AMD's family 1Ah models 0x20-0x2e, 0x60-0x6e and + # 0x70-0x7e to the ACPI device MSFT0201, and to pre-boot DMA protection (IVRS IVinfo bit 1) or + # MSFT0201 named in the IVRS: a firmware setting can take either away. if [ -r "$SYS/firmware/acpi/tables/IVRS" ]; then msft=absent for a in "$SYS"/bus/acpi/devices/MSFT0201:*; do [ -e "$a" ] && msft=present; done iv="$(od -An -tu4 -j36 -N4 "$SYS/firmware/acpi/tables/IVRS" 2>/dev/null | tr -d ' ')" case "$iv" in ''|*[!0-9]*) dp=- ;; *) dp=off; [ $(( iv & 2 )) -eq 0 ] || dp=on ;; esac - printf 'amd iommu: msft0201 %s, pre-boot dma protection %s\n' "$msft" "$dp" + named=no; LC_ALL=C grep -aq MSFT0201 "$SYS/firmware/acpi/tables/IVRS" && named=yes + printf 'amd iommu: msft0201 %s, pre-boot dma protection %s, msft0201 in ivrs %s\n' "$msft" "$dp" "$named" fi # Intel's image processing units by PCI ID: the IPU6 and IPU7 ones 6.18's drivers bind (ipu6.h, # staging ipu7.h), and IPU8's, which staging ipu7 names but does not bind yet. diff --git a/tools/tests/hwreport.sh b/tools/tests/hwreport.sh index 52f176b4..cf938d1c 100755 --- a/tools/tests/hwreport.sh +++ b/tools/tests/hwreport.sh @@ -456,21 +456,24 @@ nolap "and no AMD IOMMU line without an IVRS table" '^amd iommu: ' echo "-- what ended a sleep, and AMD's sleep needs with the IOMMU on" put sys/power/pm_wakeup_irq 9 put sys/class/wakeup/wakeup0/name PNP0C0D:00 -put sys/class/wakeup/wakeup0/wakeup_count 2 +put sys/class/wakeup/wakeup0/event_count 2 +# A wakeup_count the kernel keeps only after the handshake kryptikd does not make. +put sys/class/wakeup/wakeup0/wakeup_count 0 put sys/class/wakeup/wakeup1/name alarmtimer.0.auto -put sys/class/wakeup/wakeup1/wakeup_count 0 +put sys/class/wakeup/wakeup1/event_count 0 mkdir -p "$W/sys/firmware/acpi/tables" { head -c 36 /dev/zero; printf '\002\000\000\000'; } > "$W/sys/firmware/acpi/tables/IVRS" put sys/bus/acpi/devices/MSFT0201:00/status 15 laptop_now lap "the interrupt that ended the last sleep" '^woken by irq: 9$' -lap "a wakeup source that fired, and how often" '^wakeup source PNP0C0D:00 wakeup_count 2$' +lap "a wakeup source that fired, by its events" '^wakeup source PNP0C0D:00 events 2$' nolap "not one that never did" '^wakeup source alarmtimer' -lap "Pluton's device and pre-boot DMA protection, read from the firmware" '^amd iommu: msft0201 present, pre-boot dma protection on$' -{ head -c 36 /dev/zero; printf '\000\000\000\000'; } > "$W/sys/firmware/acpi/tables/IVRS" +lap "MSFT0201 and pre-boot DMA protection, read from the firmware" '^amd iommu: msft0201 present, pre-boot dma protection on, msft0201 in ivrs no$' +# The protection off, MSFT0201 gone from ACPI's devices and named in the IVRS instead. +{ head -c 36 /dev/zero; printf '\000\000\000\000'; printf 'IVHD MSFT0201'; } > "$W/sys/firmware/acpi/tables/IVRS" rm -rf "$W/sys/bus/acpi/devices/MSFT0201:00" laptop_now -lap "either taken away by a firmware setting" '^amd iommu: msft0201 absent, pre-boot dma protection off$' +lap "each taken away, and MSFT0201 named in the IVRS" '^amd iommu: msft0201 absent, pre-boot dma protection off, msft0201 in ivrs yes$' echo echo "${PASS} passed, ${FAIL} failed" From c9d7a9542c736dd490a5b8274b21e1bb6897aee0 Mon Sep 17 00:00:00 2001 From: DevomB Date: Fri, 23 Oct 2026 04:38:38 -0700 Subject: [PATCH 05/21] The guide's AMD sleep note says what AMD's checker asks for, and no more --- docs/user-guide.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/docs/user-guide.md b/docs/user-guide.md index 70eb4360..f5a9d4f0 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -113,11 +113,16 @@ older EPYC models: they keep the firmware's own microcode, the kernel log says `No sha256 digest for patch ID`, and every login prompt says to update the firmware. -Leave the firmware's IOMMU, its DMA protection and Pluton on. +Leave the firmware's IOMMU, its DMA protection and Pluton on, as they come. - Without the IOMMU, every device can reach all of memory, and nothing at the login prompt says so. -- AMD's Ryzen AI 300 processors sleep with the IOMMU on only where the - firmware keeps Pluton's device and its pre-boot DMA protection. +- With the IOMMU on, AMD's own sleep checker holds the Ryzen AI 300 and + Ryzen AI Max processors to two things: + - the firmware's ACPI device `MSFT0201`; + - pre-boot DMA protection, or `MSFT0201` named in the firmware's IVRS + table. + + A firmware setting can take either away. A firmware that also keeps Microsoft's certificates in `db` boots whatever Microsoft signs, so someone with the machine in hand can start such a system From 833fad41243f4df30eac40b3ca8dbf061f97535a Mon Sep 17 00:00:00 2001 From: DevomB Date: Fri, 23 Oct 2026 04:42:04 -0700 Subject: [PATCH 06/21] The software watchdog is not fed on a machine that sleeps to idle softdog's timer is a CLOCK_MONOTONIC hrtimer with no PM callbacks (softdog.c), and on a machine that sleeps to idle its clock runs whenever the s2idle loop wakes for an event that wakes nothing. On AMD that includes amd_pmc's msleep(2500) after every cycle that reached the deepest state (pmc.c, upstream 9f5595d5f03f), and an LPS0 machine's EC keeps waking it for battery, charger and thermal notices. Nothing feeds the timer during the sleep, so a night of such events spends its 60 s and the laptop reboots in its sleep, with everything open in its zones lost; the boot's bootstatus then says nothing. The design said the drivers stop their timers across suspend, which softdog's does not. The feeder already leaves AMD's TCO timer unopened on such a machine for the same reason; it now leaves the software watchdog so too. Intel's TCO timer, which its driver stops for a sleep to idle, still catches a stopped machine there; on an AMD laptop the lockup detectors alone catch a hung kernel. The test VMs sleep in S3, where the firmware stops the timers, so the state suite's reset by the software watchdog is unchanged. tools/tests/watchdog-feeder.sh runs the feeder against stand-in timers: with s2idle in force it feeds Intel's and neither of the others, and sleeping deep it feeds all three. --- build/service-scripts/watchdog.sh | 7 +++-- docs/design/laptop.md | 14 +++++++-- docs/status.md | 7 +++-- tools/tests/watchdog-feeder.sh | 52 +++++++++++++++++++++++++++++++ 4 files changed, 73 insertions(+), 7 deletions(-) create mode 100755 tools/tests/watchdog-feeder.sh diff --git a/build/service-scripts/watchdog.sh b/build/service-scripts/watchdog.sh index 5cb3d1d8..46f44f8b 100755 --- a/build/service-scripts/watchdog.sh +++ b/build/service-scripts/watchdog.sh @@ -5,14 +5,17 @@ set -u # AMD's TCO timer counts on through a sleep to idle (s2idle): its driver does not stop it for # one, as Intel's does, and the ticks that would feed it stop. Fed, it would reset a machine # that sleeps that way a minute into each sleep, so there it is left unopened, and stopped as -# its driver leaves it. In ACPI's own sleep states the firmware stops it. +# its driver leaves it. In ACPI's own sleep states the firmware stops it. The software +# watchdog likewise: its clock runs whenever the s2idle loop wakes for an event that wakes +# nothing, amd_pmc's 2.5 s pause after each deepest sleep included, so a night of an EC's +# battery and charger events would spend its minute and reboot the sleeping machine. sleeps_to_idle() { grep -q '\[s2idle\]' /sys/power/mem_sleep 2>/dev/null; } fds=() for dev in /dev/watchdog[0-9]*; do [ -c "$dev" ] || continue name="${dev##*/}" id="$(cat "/sys/class/watchdog/${name}/identity" 2>/dev/null || echo unknown)" - if [ "$id" = "SP5100 TCO timer" ] && sleeps_to_idle; then + if { [ "$id" = "SP5100 TCO timer" ] || [ "$id" = "Software Watchdog" ]; } && sleeps_to_idle; then echo "watchdog: not feeding ${dev} (${id}): it would count through this machine's sleep to idle" continue fi diff --git a/docs/design/laptop.md b/docs/design/laptop.md index bdc1b18d..08b4d7fa 100644 --- a/docs/design/laptop.md +++ b/docs/design/laptop.md @@ -374,8 +374,18 @@ The lock's check, in `make gui-test`: next 8 s once unlocked. **The watchdog** must not reset a sleeping machine, nor one waiting at the -state prompt. The drivers stop their timers across suspend, and the feeder -starts no program, so it goes on feeding while the partition is suspended. +state prompt. Intel's timer stops across every sleep, and in ACPI's own +sleep states the firmware stops the rest. + +On a machine that sleeps to idle, two timers would go on counting through +it, so the feeder leaves both unopened: +- AMD's timer, whose driver does not stop it; +- the software watchdog, whose clock runs while the s2idle loop handles an + event that wakes nothing, amd_pmc's 2.5 s pause after each deepest sleep + included. + +The feeder starts no program, so it goes on feeding while the partition is +suspended. ### In the threat model diff --git a/docs/status.md b/docs/status.md index 6d2e7db6..522667a8 100644 --- a/docs/status.md +++ b/docs/status.md @@ -80,9 +80,10 @@ media it tested. stopping the feeder). A crashed service or a frozen desktop is not detected, because a false reboot is worse than the hang. A hung kernel is reset only by a hardware timer (Intel TCO, AMD SP5100) or the lockup - detectors. AMD's timer is not fed on a machine that sleeps to idle, as it - would count through each sleep, so there the lockup detectors alone catch - a hung kernel. + detectors. On a machine that sleeps to idle, AMD's timer and the software + watchdog are not fed, as both would count through each sleep. There + Intel's timer, where there is one, still catches a stopped machine, and + on an AMD laptop the lockup detectors alone catch a hung kernel. - NVIDIA's graphics have no driver: nouveau needs tens of megabytes of GSP firmware for each generation. Beside Intel's or AMD's on a laptop, the built-in screen works through those, a port the NVIDIA chip drives stays diff --git a/tools/tests/watchdog-feeder.sh b/tools/tests/watchdog-feeder.sh new file mode 100755 index 00000000..c0a65911 --- /dev/null +++ b/tools/tests/watchdog-feeder.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# build/service-scripts/watchdog.sh against stand-in timers: on a machine that sleeps to idle it +# leaves AMD's TCO timer and the software watchdog unfed, as both would count through a sleep, +# and feeds the rest; elsewhere it feeds every one. The script runs with its paths under a +# scratch directory, files for its devices, and one round of its loop. Offline. +set -uo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +SCRIPT="${ROOT}/build/service-scripts/watchdog.sh" +PASS=0; FAIL=0 +green() { printf ' PASS %s\n' "$1"; PASS=$((PASS + 1)); } +red() { printf ' FAIL %s\n' "$1"; FAIL=$((FAIL + 1)); } +T="$(mktemp -d)"; trap 'rm -rf "$T"' EXIT + +mkdir -p "$T/dev" +timer() { # timer N IDENTITY: /dev/watchdogN and its sysfs entry + : > "$T/dev/watchdog$1" + mkdir -p "$T/sys/class/watchdog/watchdog$1" + echo "$2" > "$T/sys/class/watchdog/watchdog$1/identity" + echo 60 > "$T/sys/class/watchdog/watchdog$1/timeout" +} +timer 0 "Software Watchdog" +timer 1 "SP5100 TCO timer" +timer 2 "iTCO_wdt" +sed -e "s|/dev/watchdog|$T/dev/watchdog|g" \ + -e "s|/sys/class/watchdog/|$T/sys/class/watchdog/|g" \ + -e "s|/sys/power/mem_sleep|$T/mem_sleep|g" \ + -e 's|\[ -c "\$dev" \]|[ -e "$dev" ]|' \ + -e 's|^while :; do$|for _ in 1; do|' \ + -e 's|^ sleep 10$| :|' \ + -e 's|exec sleep infinity|exit 0|' \ + "$SCRIPT" > "$T/watchdog.sh" +grep -q 'for _ in 1; do' "$T/watchdog.sh" && grep -q '\[ -e "\$dev" \]' "$T/watchdog.sh" \ + || { echo "watchdog.sh changed shape: its loop or its device test was not found"; exit 1; } +feeds() { # feeds MEM_SLEEP: what the feeder says + echo "$1" > "$T/mem_sleep" + timeout 20 bash "$T/watchdog.sh" 2>&1 +} + +said="$(feeds '[s2idle] deep')" +grep -q "not feeding $T/dev/watchdog0 (Software Watchdog)" <<<"$said" \ + && green "sleeping to idle, the software watchdog is not fed" || red "s2idle, softdog: $(tr '\n' '|' <<<"$said")" +grep -q "not feeding $T/dev/watchdog1 (SP5100 TCO timer)" <<<"$said" \ + && green "nor AMD's TCO timer" || red "s2idle, SP5100: $(tr '\n' '|' <<<"$said")" +grep -q "feeding $T/dev/watchdog2 (iTCO_wdt" <<<"$said" && ! grep -q "not feeding $T/dev/watchdog2" <<<"$said" \ + && green "and Intel's, which stops across that sleep, is" || red "s2idle, iTCO: $(tr '\n' '|' <<<"$said")" +said="$(feeds 's2idle [deep]')" +n="$(grep -c "^watchdog: feeding $T/dev/watchdog" <<<"$said")" +[[ "$n" -eq 3 ]] && ! grep -q 'not feeding' <<<"$said" \ + && green "sleeping deep, where the firmware stops them, all three are fed" || red "deep: $(tr '\n' '|' <<<"$said")" + +printf '\n%d passed, %d failed\n' "$PASS" "$FAIL" +[[ "$FAIL" -eq 0 ]] From 635476198ae202ff060ffc13a5982ded11a670cf Mon Sep 17 00:00:00 2001 From: DevomB Date: Fri, 23 Oct 2026 04:42:39 -0700 Subject: [PATCH 07/21] The guide's country steps give iw by its path, and name radios that keep their own rules --- docs/user-guide.md | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/docs/user-guide.md b/docs/user-guide.md index f5a9d4f0..7ada7a4c 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -460,12 +460,17 @@ world's rules: The rules go back to the world's at every disconnect. For your own country's rules until the next boot: 1. At the administration login, run `su`. -2. Run `iw reg set XX`, where `XX` is your country's two letters (`US`, - `DE`). -3. `iw reg get` shows the rules in force. +2. Run `/usr/sbin/iw reg set XX`, where `XX` is your country's two letters + (`US`, `DE`). The full path is needed, as `su` leaves `/usr/sbin` off + root's path. +3. `/usr/sbin/iw reg get` shows the rules in force. -A Wi-Fi 7 network that offers only SAE-EXT-KEY, its multi-link security, is -not joined. Have the router offer WPA3 (SAE) or WPA2 beside it. +A radio whose firmware keeps its own rules, as Intel's do, follows those +instead, and `iw reg get` names it as self-managed. + +A Wi-Fi 7 network that offers only SAE-EXT-KEY, the WPA3 mode Wi-Fi 7's +multi-link operation uses, is not joined. Have the router offer WPA3 (SAE) +or WPA2 beside it. **The local network.** Every zone with a network reaches the internet through the net zone. The network the machine itself is on, with its router, its From a9751e6dd2ed8af54a045baf0f4b43794f325358 Mon Sep 17 00:00:00 2001 From: DevomB Date: Fri, 23 Oct 2026 04:44:31 -0700 Subject: [PATCH 08/21] su gives root a PATH with /usr/sbin shadow's su sets root's PATH from login.defs' ENV_SUPATH, with or without "-", and s_shadow's edit of that line leaves PATH=/bin:/usr/bin. After su, ip, iw, cryptsetup and every other program in /usr/sbin answered "command not found". The etc step sets ENV_SUPATH to PATH=/usr/sbin:/usr/bin beside CONSOLE and SU_WHEEL_ONLY, which it already sets there, and the build fails if the line is missing. The etc step comes after the images cache key, so no image is rebuilt; s_shadow's edit stays as it is until one is. tools/tests/su-path.sh runs s_shadow's edit and etc_login_defs on shadow 4.16.0's two PATH lines and reads ENV_SUPATH back as su does. --- build/recipes/etc.sh | 28 ++++++++++++++++++---------- tools/tests/su-path.sh | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 10 deletions(-) create mode 100755 tools/tests/su-path.sh diff --git a/build/recipes/etc.sh b/build/recipes/etc.sh index 6ae2ddc8..be0bc18e 100644 --- a/build/recipes/etc.sh +++ b/build/recipes/etc.sh @@ -2,6 +2,21 @@ # etc: a stage 04 recipe, sourced by build/stages/04-base-system.sh, # which runs it in the order its list gives. +# etc_login_defs FILE: lines set in shadow's login.defs, not left to the file +# a release ships. login reads the terminals root may use from CONSOLE's file. +# su gives root ENV_SUPATH's PATH with or without "-", and shadow's step strips +# /usr/sbin from it, where ip, iw and cryptsetup live. +etc_login_defs() { + local def + for def in "CONSOLE /etc/securetty" "SU_WHEEL_ONLY yes" "ENV_SUPATH PATH=/usr/sbin:/usr/bin"; do + if grep -q "^${def%% *}[[:space:]]" "$1"; then + sed -i "s|^${def%% *}[[:space:]].*|${def}|" "$1" + else + printf '%s\n' "$def" >> "$1" + fi + done +} + # /etc/os-release and friends. BUILD_ID, the commit that built the image, is # added after the steps (at the end of this file): as this step's input it # would re-fingerprint this step, and every step after it, on each commit. @@ -51,18 +66,11 @@ EOF usermod -p '*' root grep -q '^root:\*:' /etc/shadow && echo "root: no password" || { echo "FAIL: root has a password in the image"; return 1; } : > /etc/securetty - # Both set here, not left to shadow's login.defs, which a release may - # change: login reads the terminals root may use from CONSOLE's file. - local def - for def in "CONSOLE /etc/securetty" "SU_WHEEL_ONLY yes"; do - if grep -q "^${def%% *}[[:space:]]" /etc/login.defs; then - sed -i "s|^${def%% *}[[:space:]].*|${def}|" /etc/login.defs - else - printf '%s\n' "$def" >> /etc/login.defs - fi - done + etc_login_defs /etc/login.defs grep -qx 'CONSOLE /etc/securetty' /etc/login.defs && [[ ! -s /etc/securetty ]] \ || { echo "FAIL: root is not kept off the terminals"; return 1; } + grep -qx 'ENV_SUPATH PATH=/usr/sbin:/usr/bin' /etc/login.defs \ + || { echo "FAIL: su would give root a PATH without /usr/sbin"; return 1; } # Kernel interface names (eth0, wlan0), the same on every machine: eudev's # slot-naming rule is masked. diff --git a/tools/tests/su-path.sh b/tools/tests/su-path.sh new file mode 100755 index 00000000..144bf2a5 --- /dev/null +++ b/tools/tests/su-path.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# What su gives root for PATH. s_shadow edits the PATH lines of shadow 4.16.0's login.defs, then +# s_etc's etc_login_defs sets its own lines: both run here on shadow's file as the build runs +# them, and ENV_SUPATH is read back as su reads it (su.c sets it with or without "-"). Offline. +set -uo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +# shellcheck source=build/recipes/etc.sh +source "$ROOT/build/recipes/etc.sh" + +PASS=0; FAIL=0 +green() { printf ' PASS %s\n' "$1"; PASS=$((PASS + 1)); } +red() { printf ' FAIL %s\n' "$1"; FAIL=$((FAIL + 1)); } + +T="$(mktemp -d)"; trap 'rm -rf "$T"' EXIT +printf 'ENV_SUPATH\tPATH=/sbin:/bin:/usr/sbin:/usr/bin\nENV_PATH\tPATH=/bin:/usr/bin\n' > "$T/login.defs" + +# s_shadow's own edit, as the recipe writes it. +edit="$(grep -o "/PATH=/{[^']*}" "$ROOT/build/recipes/shadow.sh")" +if [[ -n "$edit" ]] && sed -i -e "$edit" "$T/login.defs"; then + green "s_shadow's PATH edit applies" +else + red "no PATH edit found in build/recipes/shadow.sh" +fi + +etc_login_defs "$T/login.defs" +etc_login_defs "$T/login.defs" +su_path="$(sed -n 's/^ENV_SUPATH[[:space:]]\{1,\}//p' "$T/login.defs")" +[[ "$su_path" == PATH=* && ":${su_path#PATH=}:" == *:/usr/sbin:* && ":${su_path#PATH=}:" == *:/usr/bin:* ]] \ + && green "su gives root ${su_path}" || red "su gives root ${su_path:-no PATH}, without /usr/sbin or /usr/bin" +[[ "$(grep -c '^ENV_SUPATH[[:space:]]' "$T/login.defs")" -eq 1 ]] \ + && green "one ENV_SUPATH line, run twice" || red "ENV_SUPATH lines: $(grep -c '^ENV_SUPATH' "$T/login.defs")" +grep -qx 'CONSOLE /etc/securetty' "$T/login.defs" && grep -qx 'SU_WHEEL_ONLY yes' "$T/login.defs" \ + && green "root stays off the terminals, and su is wheel's" || red "CONSOLE or SU_WHEEL_ONLY is not set" +grep -q "$(printf '^ENV_PATH\tPATH=/bin:/usr/bin$')" "$T/login.defs" \ + && green "other users' su PATH is shadow's" || red "ENV_PATH changed: $(grep '^ENV_PATH' "$T/login.defs")" + +echo +echo "${PASS} passed, ${FAIL} failed" +[[ "$FAIL" -eq 0 ]] From cf733dff863b606fb50379a710dd8d9acd46e499 Mon Sep 17 00:00:00 2001 From: DevomB Date: Fri, 23 Oct 2026 04:45:19 -0700 Subject: [PATCH 09/21] The guide's country steps drop the path note #639 makes untrue, and keep the full paths --- docs/user-guide.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/user-guide.md b/docs/user-guide.md index 7ada7a4c..75b1aef6 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -461,12 +461,11 @@ The rules go back to the world's at every disconnect. For your own country's rules until the next boot: 1. At the administration login, run `su`. 2. Run `/usr/sbin/iw reg set XX`, where `XX` is your country's two letters - (`US`, `DE`). The full path is needed, as `su` leaves `/usr/sbin` off - root's path. + (`US`, `DE`). 3. `/usr/sbin/iw reg get` shows the rules in force. A radio whose firmware keeps its own rules, as Intel's do, follows those -instead, and `iw reg get` names it as self-managed. +instead, and `/usr/sbin/iw reg get` names it as self-managed. A Wi-Fi 7 network that offers only SAE-EXT-KEY, the WPA3 mode Wi-Fi 7's multi-link operation uses, is not joined. Have the router offer WPA3 (SAE) From f8b7d3dc6b41be19a839360d981b3537efacae44 Mon Sep 17 00:00:00 2001 From: DevomB Date: Fri, 23 Oct 2026 04:47:35 -0700 Subject: [PATCH 10/21] A camera still held from its last grant is refused, with the reason, not handed out wrong A program that keeps its descriptor after a grant ends (a call page whose track died when `k` or the lock took the camera back) holds uvcvideo, and with it the camera's streaming interface. The next grant then probed the let-go interfaces again and found no capture node: it failed after 3 s with "no camera: none is plugged in", or on a camera with an infrared function handed the zone that one's node instead. grant() now refuses while the driver is held and an earlier end left an interface to take again (kept()), and says to turn the camera off there first. The check comes after the zone's directory is made, so the run directory is there, and before the driver is loaded, so the let-go list is still there for the grant made once the program lets go. The camera design says so; a unit test runs kept() on a stand-in /sys. --- compartments/kryptikd/src/camera.rs | 11 +++++++++++ compartments/kryptikd/src/camera/tests.rs | 15 +++++++++++++++ docs/design/camera.md | 5 ++++- 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/compartments/kryptikd/src/camera.rs b/compartments/kryptikd/src/camera.rs index 22d29233..fc80f44e 100644 --- a/compartments/kryptikd/src/camera.rs +++ b/compartments/kryptikd/src/camera.rs @@ -104,6 +104,14 @@ fn read_trim(p: &Path) -> Option { fs::read_to_string(p).ok().map(|s| s.trim().to_string()) } +/// Whether a program kept the camera from an earlier grant: its descriptor holds the driver in +/// `sys`, and with it the streaming interface of the device in `LET_GO`, which, probed again, +/// would come back with no capture node. +fn kept(sys: &Path, run: &Path) -> bool { + run.join(LET_GO).exists() + && read_trim(&sys.join("module").join(DRIVER).join("refcnt")).is_some_and(|n| n != "0") +} + /// The camera's node in `sys`, standing for /sys: of the video4linux devices that are the first /// node of their device (`index` 0, a UVC camera's capture node and not its metadata one), the /// lowest numbered; its name, major and minor, and what the device calls itself. @@ -240,6 +248,9 @@ pub fn grant(zone: &crate::zone::Zone) -> Result { end(g)?; } let dir = zone_dir(&zone.name).map_err(|e| format!("{RUN_DIR}/{}: {e}", zone.name))?; + if kept(Path::new("/sys"), Path::new(RUN_DIR)) { + return Err("a program still holds the camera from its last grant: turn the camera off there, then give it again".into()); + } modprobe(&[DRIVER])?; let _ = fs::remove_file(Path::new(RUN_DIR).join(STAYED)); if let Ok(text) = fs::read_to_string(Path::new(RUN_DIR).join(LET_GO)) { diff --git a/compartments/kryptikd/src/camera/tests.rs b/compartments/kryptikd/src/camera/tests.rs index 2d31a334..4b73eafc 100644 --- a/compartments/kryptikd/src/camera/tests.rs +++ b/compartments/kryptikd/src/camera/tests.rs @@ -106,3 +106,18 @@ fn names_from_sysfs_are_one_component() { assert!(!is_sys_name(bad), "{bad:?}"); } } + +#[test] +fn a_camera_still_held_is_not_taken_again() { + let s = sys("kept"); + let run = s.join("run"); + fs::create_dir_all(&run).unwrap(); + fs::create_dir_all(s.join("module").join(DRIVER)).unwrap(); + fs::write(s.join("module").join(DRIVER).join("refcnt"), "1\n").unwrap(); + assert!(!kept(&s, &run), "held, but nothing to take again"); + fs::write(run.join(LET_GO), "usb 3-6:1.0\n").unwrap(); + assert!(kept(&s, &run), "held, with an interface to take again"); + fs::write(s.join("module").join(DRIVER).join("refcnt"), "0\n").unwrap(); + assert!(!kept(&s, &run), "let go of"); + let _ = fs::remove_dir_all(&s); +} diff --git a/docs/design/camera.md b/docs/design/camera.md index 55ece9b4..63aaad35 100644 --- a/docs/design/camera.md +++ b/docs/design/camera.md @@ -86,7 +86,10 @@ loaded only for a grant. (`modprobe -r`, which takes the V4L2 core and videobuf2 with it once nothing uses them). Should the module not unload, something still holding it, the grant ends all the same, the node gone and the driver - unbound; the module stays until the next grant, and the chrome says so. + unbound; the module stays, and the chrome says so. A program that keeps + its descriptor also keeps the driver's hold on the camera's streaming + interface, which the camera probed again could not take. So a grant is + refused, with the reason, until that program lets go. - **What the user sees.** While a zone holds the camera, a window of the chrome's names the zone, as the microphone's does. The camera's light, on machines that tie it to the sensor in hardware, says when it captures. From 76e122c427a58665dd18978bcc781e0b11fe6cbd Mon Sep 17 00:00:00 2001 From: DevomB Date: Fri, 23 Oct 2026 04:49:02 -0700 Subject: [PATCH 11/21] The state suite's watchdog reset runs with S3, and the idle-sleep rule is checked in a guest From 93's read: QEMU hides S3 unless run-ovmf.sh is given --s3, so every suite guest but suspend-test's sleeps only to idle, and there the feeder now leaves the software watchdog unfed. state-p6 resets by that watchdog and checks it armed for 60 s, so it now starts with --s3: the guest sleeps deep, the feeder arms the software watchdog, and the step proves its reset as before (p6 uses no desktop, so std VGA in place of virtio-gpu is no loss). state-p5b, which boots without --s3, now checks that the boot report shows the software watchdog unfed: the rule seen in a guest. The feeder's fixture test also checks that its mem_sleep read was moved under the scratch directory, so a changed path cannot make it read the runner's own. --- tools/image/state-test.sh | 7 ++++++- tools/tests/watchdog-feeder.sh | 3 ++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/tools/image/state-test.sh b/tools/image/state-test.sh index e879895b..154ed87d 100755 --- a/tools/image/state-test.sh +++ b/tools/image/state-test.sh @@ -167,10 +167,15 @@ sfdisk --part-label "$DISK" 4 not-kryptik >/dev/null 2>&1 || die "relabel" degraded_boot state-p5 'no partition labelled kryptik-state on /dev/vda' sfdisk --part-label "$DISK" 4 kryptik-state >/dev/null 2>&1 || die "relabel back" normal_boot state-p5b +# QEMU without --s3 sleeps only to idle, where the software watchdog is not fed (watchdog.sh). +txt | grep -qE 'KRYPTIK_SMOKE: watchdog watchdog[0-9]+: Software Watchdog state=inactive' \ + && green "state-p5b: sleeping to idle, the software watchdog is not fed" \ + || red "state-p5b: the software watchdog is fed on a machine that sleeps to idle" # ----------------------------------------------------------------- step 6 -- step "step 6: nothing feeds the watchdog: the machine resets itself and comes back with its data" -start_vm state-p6 +# With S3 offered the guest sleeps deep, so the feeder arms the software watchdog this step resets by. +start_vm state-p6 --s3 # The feeder stopped, not killed (s6 would restart it): a hung userspace only the watchdog resets. p6=( "expect:KRYPTIK_SMOKE: END" "login:${TUSER}:${TPASS}" "$(ROOTSH 's6-svc -p /run/service/watchdog && echo FEEDER-STOPPED')" "expect:FEEDER-STOPPED" diff --git a/tools/tests/watchdog-feeder.sh b/tools/tests/watchdog-feeder.sh index c0a65911..21a83d3f 100755 --- a/tools/tests/watchdog-feeder.sh +++ b/tools/tests/watchdog-feeder.sh @@ -30,7 +30,8 @@ sed -e "s|/dev/watchdog|$T/dev/watchdog|g" \ -e 's|exec sleep infinity|exit 0|' \ "$SCRIPT" > "$T/watchdog.sh" grep -q 'for _ in 1; do' "$T/watchdog.sh" && grep -q '\[ -e "\$dev" \]' "$T/watchdog.sh" \ - || { echo "watchdog.sh changed shape: its loop or its device test was not found"; exit 1; } + && grep -q "$T/mem_sleep" "$T/watchdog.sh" \ + || { echo "watchdog.sh changed shape: its loop, its device test or its mem_sleep read was not found"; exit 1; } feeds() { # feeds MEM_SLEEP: what the feeder says echo "$1" > "$T/mem_sleep" timeout 20 bash "$T/watchdog.sh" 2>&1 From fcd8451f6c408da363e882517cf6e747136fc732 Mon Sep 17 00:00:00 2001 From: DevomB Date: Fri, 23 Oct 2026 04:53:49 -0700 Subject: [PATCH 12/21] state-p5b's idle-sleep check also needs Intel's timer armed in the same report Without it the check would pass on a report written before the feeder opened anything. Intel's TCO timer is fed whichever way the machine sleeps, so seeing it active means the feeder had made its choices. --- tools/image/state-test.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tools/image/state-test.sh b/tools/image/state-test.sh index 154ed87d..03b440c2 100755 --- a/tools/image/state-test.sh +++ b/tools/image/state-test.sh @@ -168,7 +168,9 @@ degraded_boot state-p5 'no partition labelled kryptik-state on /dev/vda' sfdisk --part-label "$DISK" 4 kryptik-state >/dev/null 2>&1 || die "relabel back" normal_boot state-p5b # QEMU without --s3 sleeps only to idle, where the software watchdog is not fed (watchdog.sh). -txt | grep -qE 'KRYPTIK_SMOKE: watchdog watchdog[0-9]+: Software Watchdog state=inactive' \ +# Intel's timer, fed either way, shows the feeder had run when the report was written. +txt | grep -qE 'KRYPTIK_SMOKE: watchdog watchdog[0-9]+: iTCO_wdt state=active' \ + && txt | grep -qE 'KRYPTIK_SMOKE: watchdog watchdog[0-9]+: Software Watchdog state=inactive' \ && green "state-p5b: sleeping to idle, the software watchdog is not fed" \ || red "state-p5b: the software watchdog is fed on a machine that sleeps to idle" From d8fdf1891346417295960d58d13c3d23165d304f Mon Sep 17 00:00:00 2001 From: DevomB Date: Fri, 23 Oct 2026 04:54:13 -0700 Subject: [PATCH 13/21] state-p5b's failure message names both halves of its check --- tools/image/state-test.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/image/state-test.sh b/tools/image/state-test.sh index 03b440c2..e3b6a09c 100755 --- a/tools/image/state-test.sh +++ b/tools/image/state-test.sh @@ -172,7 +172,7 @@ normal_boot state-p5b txt | grep -qE 'KRYPTIK_SMOKE: watchdog watchdog[0-9]+: iTCO_wdt state=active' \ && txt | grep -qE 'KRYPTIK_SMOKE: watchdog watchdog[0-9]+: Software Watchdog state=inactive' \ && green "state-p5b: sleeping to idle, the software watchdog is not fed" \ - || red "state-p5b: the software watchdog is fed on a machine that sleeps to idle" + || red "state-p5b: the boot report does not show Intel's timer fed and the software watchdog unfed" # ----------------------------------------------------------------- step 6 -- step "step 6: nothing feeds the watchdog: the machine resets itself and comes back with its data" From a7bf2f10735fa39a828c6439587eba25ae154386 Mon Sep 17 00:00:00 2001 From: DevomB Date: Fri, 23 Oct 2026 04:54:47 -0700 Subject: [PATCH 14/21] The held-camera test also covers uvcvideo unloaded --- compartments/kryptikd/src/camera/tests.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/compartments/kryptikd/src/camera/tests.rs b/compartments/kryptikd/src/camera/tests.rs index 4b73eafc..8a6efca0 100644 --- a/compartments/kryptikd/src/camera/tests.rs +++ b/compartments/kryptikd/src/camera/tests.rs @@ -119,5 +119,7 @@ fn a_camera_still_held_is_not_taken_again() { assert!(kept(&s, &run), "held, with an interface to take again"); fs::write(s.join("module").join(DRIVER).join("refcnt"), "0\n").unwrap(); assert!(!kept(&s, &run), "let go of"); + fs::remove_dir_all(s.join("module").join(DRIVER)).unwrap(); + assert!(!kept(&s, &run), "unloaded"); let _ = fs::remove_dir_all(&s); } From 3aad9b6fc669ed6d3fed29c9657de0f6b75c4589 Mon Sep 17 00:00:00 2001 From: DevomB Date: Fri, 23 Oct 2026 04:59:23 -0700 Subject: [PATCH 15/21] console-dead-port.sh quotes the word its stand-in writes shellcheck reads a bare done after echo, on a loop's one line, as the loop's own done (SC1010). The quoted word writes the same bytes. --- tools/tests/console-dead-port.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/tests/console-dead-port.sh b/tools/tests/console-dead-port.sh index a6ce74a2..300c3d1a 100755 --- a/tools/tests/console-dead-port.sh +++ b/tools/tests/console-dead-port.sh @@ -22,7 +22,7 @@ body "$ROOT/build/recipes/init.sh" "cat > \"\$skel/rc.shutdown\" <<'EOF'" > "$T/ [[ -s "$T/shutdown.orig" ]] && green "rc.shutdown is in init.sh" || red "no rc.shutdown in init.sh" mkdir -p "$T/bin" "$T/run" "$T/dev" -for held in sysinit firstboot; do echo done > "$T/run/kryptik-$held"; done +for held in sysinit firstboot; do echo "done" > "$T/run/kryptik-$held"; done : > "$T/dev/ttyS0"; : > "$T/dev/tty0"; : > "$T/dev/console" echo 'ro rootwait kryptik.slot=a' > "$T/cmdline" cat > "$T/bin/stty" <<'S' From 0724b5d5ac9a34c8420e91ef7f6fed4e7eeb8ebc Mon Sep 17 00:00:00 2001 From: DevomB Date: Fri, 23 Oct 2026 05:00:55 -0700 Subject: [PATCH 16/21] A tap's press goes to the window tapped dwl's motionnotify picks the surface under the pointer before it moves the cursor. A mouse's small steps never show it, but a touch is one jump and the button at once: the press and release went to whatever the pointer had been resting on, while the keyboard went to the window tapped. A tap on one zone's window could click another's. touchpoint picks the surface again where the touch landed, as dwl's own focus-restoring call does, before the button goes out; a finger held down keeps its window as before. gui-check's touch round now rests the pointer on zone 0's window first, and holds the tap's press and release to untrusted's probe, with none for zone 0's. wlprobe's cursor mode says each pointer button it is sent. --- build/guest-tests/gui-check.sh | 18 ++++++++++++++++++ tools/desktop/dwl-zone-borders.py | 4 ++++ tools/desktop/wlprobe.c | 7 ++++++- tools/image/gui-test.sh | 3 ++- 4 files changed, 30 insertions(+), 2 deletions(-) diff --git a/build/guest-tests/gui-check.sh b/build/guest-tests/gui-check.sh index 239dea6c..38e952eb 100755 --- a/build/guest-tests/gui-check.sh +++ b/build/guest-tests/gui-check.sh @@ -21,6 +21,7 @@ # GT CONSENT-CODE 1 NN type NN and Enter (the question's code) # GT CONSENT-WAIT 2 type y and Enter (not the code: refused) # GT POINTER-ZONE0, GT POINTER-UNTRUSTED move the pointer onto the newest window +# GT TOUCH-HOVER-ZONE0 move the pointer right, onto zone 0's window # GT TOUCH-UNTRUSTED tap the screen's left half (the newest window) # GT SCREENSHOT-POPUP take a screenshot # GT KEY-MENU-POPUP press Alt+p (the chrome menu) @@ -1199,6 +1200,13 @@ mark touch untrusted launch_plain untrusted "/usr/libexec/kryptik/wlprobe cursor 30" > "$LOG/launch-touch.out" 2>&1 wait_for 20 probe_committed touch sleep 2 +# The pointer rests on zone 0's window, the right half, when the tap comes: the press must +# reach the window tapped, not the one under the pointer. zone 0's probe says each entry. +entered="$(grep -c 'set a ' "$LOG/touch-zone0.out")" +zone0_entered() { [[ "$(grep -c 'set a ' "$LOG/touch-zone0.out")" -gt "$entered" ]]; } +echo "GT TOUCH-HOVER-ZONE0" +wait_for 10 zone0_entered +hovered="$(grep -c 'set a ' "$LOG/touch-zone0.out")" touch_before="$(grep '^zone=' "$RT/kryptik/focus" 2>/dev/null)" echo "GT TOUCH-UNTRUSTED" if ! grep -q 'Name="QEMU Virtio MultiTouch"' /proc/bus/input/devices; then @@ -1210,6 +1218,16 @@ elif wait_for 10 grep -qx 'zone=untrusted' "$RT/kryptik/focus"; then else fail "touch-picks-window" "after the tap: $(tr '\n' ' ' < "$RT/kryptik/focus" 2>/dev/null); $(since_mark touch untrusted | tail -2 | tr '\n' ' ')" fi +touch_pressed() { since_mark touch untrusted | grep -q 'pointer button 272 released'; } +if [[ "$hovered" -le "$entered" ]]; then + fail "touch-presses-window" "the pointer never came onto zone 0's window before the tap, so nothing was shown: $(tail -3 "$LOG/touch-zone0.out" | tr '\n' ' ')" +elif ! wait_for 10 touch_pressed; then + fail "touch-presses-window" "untrusted's window was sent no press and release: $(since_mark touch untrusted | tail -3 | tr '\n' ' '); zone 0's: $(grep 'pointer button' "$LOG/touch-zone0.out" | tr '\n' ' ')" +elif grep -q 'pointer button' "$LOG/touch-zone0.out"; then + fail "touch-presses-window" "the tap's button also went to zone 0's window, where the pointer rested: $(grep 'pointer button' "$LOG/touch-zone0.out" | tr '\n' ' ')" +else + pass "touch-presses-window" "the tap's press and release went to untrusted's window, and none to zone 0's, under the pointer before" +fi pkill -u "$USER_NAME" -f 'wlprobe cursor 40' 2>/dev/null; wait "$touch0" 2>/dev/null # --- a zone's window hears the scale it is shown at ------------------------------------------ diff --git a/tools/desktop/dwl-zone-borders.py b/tools/desktop/dwl-zone-borders.py index c64d97b2..a44b71b1 100755 --- a/tools/desktop/dwl-zone-borders.py +++ b/tools/desktop/dwl-zone-borders.py @@ -1342,6 +1342,10 @@ \t\twlr_cursor_absolute_to_layout_coords(cursor, device, x, y, &lx, &ly); \t} \tmotionnotify(time, device, lx - cursor->x, ly - cursor->y, lx - cursor->x, ly - cursor->y); +\t/* motionnotify picks the surface before it moves the cursor, which a mouse's small steps +\t * never show. A touch jumps, so the surface is picked again where it landed, or the press +\t * would go to whatever the pointer rested on, another zone's window as likely as not. */ +\tmotionnotify(0, NULL, 0, 0, 0, 0); } /* Kryptik: the left button, as buttonpress takes it from a pointer. */ diff --git a/tools/desktop/wlprobe.c b/tools/desktop/wlprobe.c index faa0e9c2..c850c063 100644 --- a/tools/desktop/wlprobe.c +++ b/tools/desktop/wlprobe.c @@ -35,7 +35,8 @@ * its middle: drawn, it covers the whole screen. * Says when the window, and when the cursor image, * enters an output: the second only if the - * compositor took the image. With shape, also ask + * compositor took the image, and each pointer + * button it is sent. With shape, also ask * for the text cursor by name (wp_cursor_shape_v1) * wlprobe animate SECONDS as oversize 0, and once drawn commit again on * every frame callback for SECONDS; then say how @@ -616,6 +617,10 @@ static int handle_one(void) draw_popup(); } else if (cursor && object == pointer_id && opcode == 0) { set_cursor(get32(body)); /* wl_pointer.enter(serial, surface, x, y) */ + } else if (cursor && object == pointer_id && opcode == 3) { + /* wl_pointer.button(serial, time, button, state) */ + printf("pointer button %u %s\n", get32(body + 8), get32(body + 12) ? "pressed" : "released"); + fflush(stdout); } else if (cursor && opcode == 0 && (object == SURFACE || (cursor_surf && object == cursor_surf))) { /* wl_surface.enter(output) */ printf("%s entered an output\n", object == SURFACE ? "the window" : "the cursor image"); diff --git a/tools/image/gui-test.sh b/tools/image/gui-test.sh index 9f25aa0a..8e500ae0 100755 --- a/tools/image/gui-test.sh +++ b/tools/image/gui-test.sh @@ -116,6 +116,7 @@ DRIVE_TIMEOUT=600 drive --qmp "$QMP" \ "expect:GT CONSENT-WAIT 2" "key:y" "key:ret" \ "expect:GT POINTER-ZONE0" "pointer:-4000,-4000" "pointer:120,120" \ "expect:GT POINTER-UNTRUSTED" "pointer:-4000,-4000" "pointer:120,120" \ + "expect:GT TOUCH-HOVER-ZONE0" "pointer:4000,0" "pointer:-80,0" "pointer:-1,0" \ "expect:GT TOUCH-UNTRUSTED" "touch:250,500" \ "expect:GT SCREENSHOT-POPUP" "sleep:2" "screendump:${SHOT_POPUP}" \ "expect:GT KEY-MENU-POPUP" "key:alt+p" \ @@ -184,7 +185,7 @@ for name in session-socket compositor-running desktop-memory-bound chrome-focus- second-head-scale-chosen zone-sees-heads-unnamed \ vt-admin-login vt-desktop-back vt-desktop-takes-keys dwl-execs-nothing dwl-without-group seat-is-dwls dwl-confined-to-its-files \ volume-key lock-passes-volume-keys lock-holds-other-keys \ - zone0-cursor-set zone0-cursor-shown zone-cursor-asked zone-hears-of-outputs zone-cursor-not-shown zone-cursor-by-name touch-picks-window zone-fractional-scale compositor-socket-unreached \ + zone0-cursor-set zone0-cursor-shown zone-cursor-asked zone-hears-of-outputs zone-cursor-not-shown zone-cursor-by-name touch-picks-window touch-presses-window zone-fractional-scale compositor-socket-unreached \ zone0-frame-clock zone-window-animates \ status-network-line-user \ zone0-fullscreen-granted zone0-fullscreen-needs-focus fullscreen-by-key marks-fullscreen zone-child-mapped child-focused parent-focused parent-fullscreen fullscreen-keeps-focus keyboard-stays-on-fullscreen zoom-keeps-keyboard zoom-twice-keeps-keyboard zone0-over-fullscreen-gets-keyboard zone0-window-ends-fullscreen child-ends-fullscreen; do From e453db04cd67cd4316756523b9262f1ee756842b Mon Sep 17 00:00:00 2001 From: DevomB Date: Fri, 23 Oct 2026 05:04:28 -0700 Subject: [PATCH 17/21] No touch acts through a shut lid or on a panel that is off Closed on a dock, the panel stays on, and a touch through the lid would click, and take the keyboard, on a screen no one sees, and hold off the idle lock. touchdown drops every touch while the lid is shut. touchpoint drops one, too, while a built-in panel is there but off, dark or disabled, rather than spread it over the other screens. A finger already down still ends through touchup or touchcancel. cleanuplisteners removes the five touch listeners with the cursor's own. One touchscreen is taken to be the laptop's, which the comment now says. --- tools/desktop/dwl-zone-borders.py | 31 ++++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/tools/desktop/dwl-zone-borders.py b/tools/desktop/dwl-zone-borders.py index a44b71b1..cff6881e 100755 --- a/tools/desktop/dwl-zone-borders.py +++ b/tools/desktop/dwl-zone-borders.py @@ -1302,6 +1302,15 @@ \twl_signal_add(&cursor->events.touch_up, &cursor_touch_up); \twl_signal_add(&cursor->events.touch_cancel, &cursor_touch_cancel); \twl_signal_add(&cursor->events.touch_frame, &cursor_touch_frame); +"""), + ("""\twl_list_remove(&cursor_motion_absolute.link); +""", + """\twl_list_remove(&cursor_motion_absolute.link); +\twl_list_remove(&cursor_touch_down.link); +\twl_list_remove(&cursor_touch_motion.link); +\twl_list_remove(&cursor_touch_up.link); +\twl_list_remove(&cursor_touch_cancel.link); +\twl_list_remove(&cursor_touch_frame.link); """), ("""\tcase WLR_INPUT_DEVICE_SWITCH: \t\tcreateswitch(wlr_switch_from_input_device(device)); @@ -1322,19 +1331,27 @@ static int32_t touchid = -1; /* Kryptik: the pointer to where a touch is: on the built-in panel when there is one, as a - * laptop's touchscreen is its own, and over every screen otherwise. */ -static void + * laptop's touchscreen is its own, and over every screen otherwise. One touchscreen is taken + * to be the laptop's, so a touch monitor on a desk drives the panel too. 0, the touch dropped, + * while the panel is there but off: dark, or shut away, a touch on it lands where no one sees. */ +static int touchpoint(struct wlr_input_device *device, uint32_t time, double x, double y) { \tstruct wlr_box box = {0}; \tdouble lx, ly; +\tint panel = 0; \tMonitor *m; \twl_list_for_each(m, &mons, link) { \t\tconst char *n = m->wlr_output->name; -\t\tif (m->wlr_output->enabled && (!strncmp(n, "eDP", 3) || !strncmp(n, "LVDS", 4) || !strncmp(n, "DSI", 3))) +\t\tif (strncmp(n, "eDP", 3) && strncmp(n, "LVDS", 4) && strncmp(n, "DSI", 3)) +\t\t\tcontinue; +\t\tpanel = 1; +\t\tif (m->wlr_output->enabled) \t\t\tbox = m->m; \t} +\tif (panel && wlr_box_empty(&box)) +\t\treturn 0; \tif (!wlr_box_empty(&box)) { \t\tlx = box.x + x * box.width; \t\tly = box.y + y * box.height; @@ -1346,6 +1363,7 @@ \t * never show. A touch jumps, so the surface is picked again where it landed, or the press \t * would go to whatever the pointer rested on, another zone's window as likely as not. */ \tmotionnotify(0, NULL, 0, 0, 0, 0); +\treturn 1; } /* Kryptik: the left button, as buttonpress takes it from a pointer. */ @@ -1360,10 +1378,13 @@ touchdown(struct wl_listener *listener, void *data) { \tstruct wlr_touch_down_event *event = data; -\tif (touchid != -1) +\t/* Through a lid shut on a dock, a touch is no one's: it would click, and take the keyboard, +\t * on a screen no one sees, and hold off the idle lock. */ +\tif (touchid != -1 || lidshut) +\t\treturn; +\tif (!touchpoint(&event->touch->base, event->time_msec, event->x, event->y)) \t\treturn; \ttouchid = event->touch_id; -\ttouchpoint(&event->touch->base, event->time_msec, event->x, event->y); \ttouchbutton(event->time_msec, WL_POINTER_BUTTON_STATE_PRESSED); } From 004b992b576fdb36edc36642b92b3a0cf7b85bf2 Mon Sep 17 00:00:00 2001 From: DevomB Date: Fri, 23 Oct 2026 05:07:45 -0700 Subject: [PATCH 18/21] The scale docs say what a zone is told, and what auto gives back Since the proxy passes wp_fractional_scale_v1, a zone's window is told the exact scale, and the browser draws sharp at it. The user guide and laptop.md still said zones hear a whole number, rounded up. havoc and the lock draw at 1 and are enlarged, so they look soft at any scale but 1, which the guide now says beside its suggestion of 1.5. `kryptik display NAME auto` drops the monitor's line, so a `*` line wins over the built-in rule, which also needs 1200 lines; the guide and the command's usage now say both. --- docs/design/laptop.md | 9 ++++++--- docs/user-guide.md | 10 ++++++---- tools/kryptik | 5 +++-- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/docs/design/laptop.md b/docs/design/laptop.md index bdc1b18d..9d99b940 100644 --- a/docs/design/laptop.md +++ b/docs/design/laptop.md @@ -638,9 +638,12 @@ stops listing until it is on again, is never turned on by it. A monitor no line names gets 2 if it is a built-in panel (eDP, LVDS, DSI) of 192 dpi or more and 1200 lines or more, as GNOME decides, and 1 otherwise: a television's EDID may give its aspect ratio as its size, so only built-in -panels are measured. Zones are told the scale through `wl_output`, rounded -up to a whole number, and draw that much larger; the compositor scales them -down to a fraction such as 1.5. dwl and the proxy needed no change. +panels are measured. A zone's window is told the exact scale through +`wp_fractional_scale_v1`, which the proxy passes on (`policy.rs`), and a +client that reads only `wl_output`'s whole number draws at that, which the +compositor scales down to the fraction. havoc and kryptik-lock draw at 1 at +every scale, so the compositor enlarges them, and they look soft at any scale +but 1. Check: `make gui-test` writes the user's line for the first monitor at 1 and `* 2`, plugs a second monitor in, and finds it at scale 2 and the first at 1; diff --git a/docs/user-guide.md b/docs/user-guide.md index 9c3876de..75ec43ad 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -462,10 +462,12 @@ SCALE` draws everything on that monitor SCALE times as large, from 0.5 to 4, 1.5 say on a laptop's high-resolution panel, and `kryptik display NAME SCALE X Y` also puts its top left corner at X,Y. The choice is kept for every login and made again whenever the monitor is plugged in; NAME `*` stands for -every monitor no other line names, and `kryptik display NAME auto` gives the -monitor back its own: 2 for a built-in panel of 192 dpi or more, 1 for the -rest. Zones are told the scale rounded up to a whole number, and draw that -much larger. +every monitor no other line names. `kryptik display NAME auto` drops that +monitor's line, so it takes the `*` line if there is one, and otherwise 2 for a +built-in panel of 192 dpi or more and 1200 lines or more, 1 for the rest. A +zone's window is told the exact scale, and the browser draws sharp at it. +Kryptik's terminals, the menu and the lock screen draw at 1 and are enlarged, +so at any scale but 1 they look soft. **USB sticks.** Zone 0 reads nothing on a USB stick or an SD card. Plugging one in opens a window of the chrome that names each partition by what the diff --git a/tools/kryptik b/tools/kryptik index ebb7880e..79cd4f98 100755 --- a/tools/kryptik +++ b/tools/kryptik @@ -76,8 +76,9 @@ USAGE: everything half as large again), its top left corner at X,Y if given; kept for every login; NAME * is any monitor no line names - $PROG display NAME auto that monitor as it comes: 2 for a built-in - panel of 192 dpi or more, else 1 + $PROG display NAME auto drop that monitor's line: the * line then, + if any, else 2 for a built-in panel of 192 + dpi and 1200 lines or more, else 1 OPTIONS: --zones DIR override the zone directory (default: $ZONES_DIR) From 9ae3a44bd4cc597f8a3d2f409996ad749ab00794 Mon Sep 17 00:00:00 2001 From: DevomB Date: Fri, 23 Oct 2026 05:10:52 -0700 Subject: [PATCH 19/21] A laptop woken at the passphrase prompt powers off at 3% instead of running flat The battery's ACPI driver wakes an s2idle machine when the charge falls to the firmware's alarm level or goes critical (drivers/acpi/battery.c, acpi_battery_update). The laptop then waits at the state passphrase prompt, lid still shut, and the power service waits on the suspended state partition, as every program started then does, so its 3% power-off never comes and the laptop runs flat awake. kryptikd already answers the lock while the partition is suspended, from sysfs alone. From each sleep's end until the passphrase is given (or from its start, for a daemon started again at the prompt) it now looks at the battery every 30 s, by power.sh's rule: the fullest of the machine's own batteries at or under 3% and running down. Seen twice running, it powers off with reboot(2). The zones are not stopped first: they are frozen, the partition and their volumes have no key, everything was written out before the seal, and a sync would wait on the partition for good. critical_in is held to tools/tests/power.sh's cases in a unit test, and the twice-running count to its own. --- compartments/kryptikd/src/serve.rs | 34 ++++++++- compartments/kryptikd/src/suspend.rs | 64 +++++++++++++++++ compartments/kryptikd/src/suspend/tests.rs | 80 ++++++++++++++++++++++ docs/design/laptop.md | 11 +++ 4 files changed, 188 insertions(+), 1 deletion(-) diff --git a/compartments/kryptikd/src/serve.rs b/compartments/kryptikd/src/serve.rs index 721e1bfa..5ba5d0a8 100644 --- a/compartments/kryptikd/src/serve.rs +++ b/compartments/kryptikd/src/serve.rs @@ -1923,6 +1923,8 @@ fn handle(cfg: &ServeConfig, conn: UnixStream, jobs: &mut Vec, pauses: &mut /// How often the login prompt's update notice is checked. const NOTICE_EVERY: Duration = Duration::from_secs(3600); +/// How often the battery is looked at while the state partition is suspended: power.sh's round. +const SEALED_EVERY: Duration = Duration::from_secs(30); pub fn cmd_serve(zones_dir: &Path, args: &[String]) -> ExitCode { // First, before anything that could read /etc or /var: a daemon started again while the state @@ -1977,7 +1979,30 @@ pub fn cmd_serve(zones_dir: &Path, args: &[String]) -> ExitCode { // The login prompt carries what `kryptik update status` says of a stale statement, on the // installed system's instance: checked as it starts, then hourly, as days are what count. let mut notice_due = (!cfg.developer).then(Instant::now); + // From each wake until the passphrase is given, power.sh waits on the state partition, so + // its 3% rule is kept here: a laptop the battery's alarm woke, its lid shut, would + // otherwise run flat at the prompt. A daemon started again at the prompt looks too. + let mut sealed_due = (!cfg.developer && crate::suspend::state_suspended()).then(Instant::now); + let mut sealed_low = 0; loop { + if sealed_due.is_some_and(|due| Instant::now() >= due) { + sealed_due = None; + // A sleep under way ends with a wake, which looks afresh. + if jobs.iter().any(|j| j.what == JobKind::Sleep) || !crate::suspend::state_suspended() { + sealed_low = 0; + } else { + let (low, off) = crate::suspend::sealed_look(sealed_low, crate::suspend::critical()); + sealed_low = low; + sealed_due = Some(Instant::now() + SEALED_EVERY); + if off { + eprintln!("kryptikd serve: the battery is at {}% or under, the state partition suspended: powering off", crate::suspend::CRITICAL); + let e = crate::suspend::power_off(); + // Once: the next look would be refused the same way. + eprintln!("kryptikd serve: the machine did not power off: {e}"); + sealed_due = None; + } + } + } if notice_due.is_some_and(|due| Instant::now() >= due) { // The update state is on the state partition: while that is suspended the notice // keeps, and the daemon stays free to answer the screen lock. @@ -1992,13 +2017,20 @@ pub fn cmd_serve(zones_dir: &Path, args: &[String]) -> ExitCode { notice_due = Some(Instant::now() + NOTICE_EVERY); } reap(&mut pending, &mut jobs, &mut proxies); + let mut slept = false; jobs.retain_mut(|j| match j.exited { Some(st) => { + slept |= j.what == JobKind::Sleep; finish_job(j, st); false } None => true, }); + // Woken or refused, a sleep leaves the state partition suspended. + if slept && !cfg.developer { + sealed_due = Some(Instant::now()); + sealed_low = 0; + } // fds[0] is the listener, then one per launch awaiting readiness; timeout: nearest deadline. let mut fds: Vec = vec![libc::pollfd { fd: listener.as_raw_fd(), events: libc::POLLIN, revents: 0 }]; @@ -2013,7 +2045,7 @@ pub fn cmd_serve(zones_dir: &Path, args: &[String]) -> ExitCode { let left = p.deadline().saturating_duration_since(now).as_millis() as i32; timeout = if timeout < 0 { left } else { timeout.min(left) }; } - if let Some(due) = notice_due { + for due in [notice_due, sealed_due].into_iter().flatten() { let left = due.saturating_duration_since(now).as_millis().min(i32::MAX as u128) as i32; timeout = if timeout < 0 { left } else { timeout.min(left) }; } diff --git a/compartments/kryptikd/src/suspend.rs b/compartments/kryptikd/src/suspend.rs index 9402d3d8..7a2e4f66 100644 --- a/compartments/kryptikd/src/suspend.rs +++ b/compartments/kryptikd/src/suspend.rs @@ -238,6 +238,70 @@ pub fn sleep() -> Result<(), String> { .map_err(|e| format!("/sys/power/state: {e}")) } +/// The charge, in percent, at or under which a battery running down powers the machine off +/// (power.sh's CRITICAL). +pub const CRITICAL: u64 = 3; + +fn attr(dir: &Path, name: &str) -> Option { + fs::read_to_string(dir.join(name)).ok().map(|s| s.trim().to_string()) +} + +/// A battery's charge in percent: its capacity, or the energy or the charge it holds against +/// what it holds full. +fn percent(bat: &Path) -> Option { + if let Some(c) = attr(bat, "capacity").filter(|c| !c.is_empty() && c.bytes().all(|b| b.is_ascii_digit())) { + return c.parse().ok(); + } + ["energy", "charge"].iter().find_map(|k| { + let now = attr(bat, &format!("{k}_now"))?.parse::().ok()?; + let full = attr(bat, &format!("{k}_full"))?.parse::().ok().filter(|&f| f > 0)?; + Some((now.saturating_mul(100) / full).min(100)) + }) +} + +/// Whether the battery is critical as power.sh judges it, from what `sys` says: the fullest of +/// the machine's own batteries (not a mouse's, scope Device) at or under CRITICAL, and running +/// down: one says it discharges, or none says anything and no adapter is online. power.sh waits +/// on the state partition while it is suspended, as anything started then does, so the daemon +/// keeps the rule meanwhile; the two must agree. +pub fn critical_in(sys: &Path) -> bool { + let Ok(rd) = fs::read_dir(sys.join("class/power_supply")) else { return false }; + let all: Vec<(PathBuf, String)> = rd.flatten().filter_map(|e| Some((e.path(), attr(&e.path(), "type")?))).collect(); + let mains = all.iter().any(|(d, t)| matches!(t.as_str(), "Mains" | "USB") && attr(d, "online").as_deref() == Some("1")); + let bats: Vec<&PathBuf> = all + .iter() + .filter(|(d, t)| t == "Battery" && attr(d, "scope").as_deref() != Some("Device") && attr(d, "present").is_none_or(|p| p == "1")) + .map(|(d, _)| d) + .collect(); + let Some(charge) = bats.iter().filter_map(|d| percent(d)).max() else { return false }; + let said: Vec = bats.iter().filter_map(|d| attr(d, "status")).collect(); + let draining = said.iter().any(|s| s == "Discharging") + || (!said.iter().any(|s| matches!(s.as_str(), "Charging" | "Full" | "Not charging")) && !mains); + draining && charge <= CRITICAL +} + +pub fn critical() -> bool { + critical_in(Path::new("/sys")) +} + +/// One look at the battery while the state partition is suspended, after `low` critical looks +/// in a row: the count now, and whether to power off. Twice running, as power.sh has it, so one +/// odd reading does not end the session. +pub fn sealed_look(low: u32, critical: bool) -> (u32, bool) { + let low = if critical { low + 1 } else { 0 }; + (low, low >= 2) +} + +/// Off at once, the battery running out with the state partition suspended. Everything was +/// written out before the seal, nothing can be written to the partition now, and a sync, or +/// the shutdown that would stop the zones first, would wait on it for good. Returns only with +/// why the kernel refused. +pub fn power_off() -> std::io::Error { + // SAFETY: reboot takes a command alone, and RB_POWER_OFF does not return when it works. + unsafe { libc::reboot(libc::RB_POWER_OFF) }; + std::io::Error::last_os_error() +} + #[derive(Debug)] pub enum ResumeError { /// cryptsetup exited 2: no keyslot took the passphrase. diff --git a/compartments/kryptikd/src/suspend/tests.rs b/compartments/kryptikd/src/suspend/tests.rs index 803c72e9..facf08c8 100644 --- a/compartments/kryptikd/src/suspend/tests.rs +++ b/compartments/kryptikd/src/suspend/tests.rs @@ -87,3 +87,83 @@ fn a_seal_that_left_a_zone_waiting_counts_itself() { count_left_waiting(&dir.join("absent").join("left-waiting")); let _ = fs::remove_dir_all(&dir); } + +/// A power supply of the kernel's shape under `sys`: a directory with its type and attributes. +fn supply(sys: &Path, name: &str, kind: &str, attrs: &[(&str, &str)]) { + let d = sys.join("class/power_supply").join(name); + fs::create_dir_all(&d).unwrap(); + fs::write(d.join("type"), format!("{kind}\n")).unwrap(); + for (k, v) in attrs { + fs::write(d.join(k), format!("{v}\n")).unwrap(); + } +} + +fn fresh_sys(tag: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("kryptikd-{tag}-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + dir +} + +// The cases of tools/tests/power.sh that decide power.sh's critical=: the two must agree. +#[test] +fn the_battery_is_critical_as_power_sh_has_it() { + let s = fresh_sys("power-none"); + assert!(!critical_in(&s), "no supply at all, as in a VM"); + assert!(!critical_in(&s.join("absent")), "no power_supply class"); + + let s = fresh_sys("power-low"); + supply(&s, "AC", "Mains", &[("online", "0")]); + supply(&s, "BAT0", "Battery", &[("capacity", "3"), ("status", "Discharging")]); + assert!(critical_in(&s), "3% on battery"); + supply(&s, "BAT1", "Battery", &[("capacity", "60"), ("status", "Discharging")]); + assert!(!critical_in(&s), "but not while a second battery is full enough"); + + let s = fresh_sys("power-four"); + supply(&s, "BAT0", "Battery", &[("capacity", "4"), ("status", "Discharging")]); + assert!(!critical_in(&s), "4% is not yet"); + + let s = fresh_sys("power-weak"); + supply(&s, "AC", "Mains", &[("online", "1")]); + supply(&s, "BAT0", "Battery", &[("capacity", "2"), ("status", "Discharging")]); + assert!(critical_in(&s), "plugged in to a charger that gives less than the machine uses, it runs down"); + supply(&s, "BAT0", "Battery", &[("status", "Charging")]); + assert!(!critical_in(&s), "2% charging on mains"); + + let s = fresh_sys("power-word"); + supply(&s, "BAT0", "Battery", &[("capacity", "2"), ("status", "Unknown")]); + assert!(critical_in(&s), "no word from the battery and no adapter: it runs down"); + supply(&s, "ucsi", "USB", &[("online", "1")]); + assert!(!critical_in(&s), "no word from the battery, a USB charger online"); + supply(&s, "BAT0", "Battery", &[("status", "Not charging")]); + supply(&s, "ucsi", "USB", &[("online", "0")]); + assert!(!critical_in(&s), "one that holds its charge"); + + let s = fresh_sys("power-other"); + supply(&s, "hidpp_battery_0", "Battery", &[("capacity", "1"), ("scope", "Device"), ("status", "Discharging")]); + assert!(!critical_in(&s), "a mouse's empty battery is not the machine's"); + supply(&s, "BAT0", "Battery", &[("capacity", "2"), ("present", "0"), ("status", "Discharging")]); + supply(&s, "BAT1", "Battery", &[("capacity", "fail"), ("status", "Discharging")]); + assert!(!critical_in(&s), "an absent battery and a bad reading count for nothing"); + + let s = fresh_sys("power-energy"); + supply(&s, "BAT0", "Battery", &[("capacity", "fail"), ("energy_now", "5130000"), ("energy_full", "57000000"), ("status", "Discharging")]); + assert!(!critical_in(&s), "with no capacity that reads, the energy against full: 9%"); + let s = fresh_sys("power-charge"); + supply(&s, "BAT0", "Battery", &[("charge_now", "120000"), ("charge_full", "4000000"), ("status", "Discharging")]); + assert!(critical_in(&s), "or the charge against full: 3%"); + supply(&s, "BAT0", "Battery", &[("charge_full", "0")]); + assert!(!critical_in(&s), "a battery full at nothing gives no charge"); + + for tag in ["none", "low", "four", "weak", "word", "other", "energy", "charge"] { + let _ = fs::remove_dir_all(std::env::temp_dir().join(format!("kryptikd-power-{tag}-{}", std::process::id()))); + } +} + +#[test] +fn critical_twice_running_powers_off() { + assert_eq!(sealed_look(0, true), (1, false), "one odd reading does not end the session"); + assert_eq!(sealed_look(1, true), (2, true), "the second, 30 s on, does"); + assert_eq!(sealed_look(1, false), (0, false), "a reading above starts the count again"); + assert_eq!(sealed_look(0, false), (0, false)); +} diff --git a/docs/design/laptop.md b/docs/design/laptop.md index bdc1b18d..6c03b6fb 100644 --- a/docs/design/laptop.md +++ b/docs/design/laptop.md @@ -465,6 +465,17 @@ and the power key, idle and inhibitors, suspend, and runtime directories since there is no hibernation. Each look is written to `/run/kryptik-power/state`, which the chrome reads, so the menu says what the service acts on. +- **The battery while the state partition waits.** From a wake until the + passphrase is given, the power service waits on the partition, as every + program started then does. A battery reaching its alarm level wakes an + s2idle laptop, so a laptop asleep in a bag would wake to the prompt, + lid still shut, and run flat there. kryptikd keeps the 3% rule + meanwhile, from `/sys` alone (`critical` in `suspend.rs`, held to + power.sh's cases): seen twice 30 s apart, it powers the machine off at + once (`reboot(2)`). The zones are frozen and their volumes and the + partition have no key, so they are not stopped first: everything was + written out before the seal, nothing can be written now, and a sync + would wait for good. - **A gauge that reads too low.** A worn battery whose gauge says 3% or less while it still holds charge powers the machine off a minute after every boot on battery. Requiring a reading above 3% earlier in the boot would From 87e4652e0345502cbf9aaf9feef69816a4c929c3 Mon Sep 17 00:00:00 2001 From: DevomB Date: Fri, 23 Oct 2026 05:19:15 -0700 Subject: [PATCH 20/21] The power service leaves a sealed machine's 3% power-off to kryptikd, and the count carries across a sleep The suspend suite's guest runs programs at the passphrase prompt, so the power service's loop is not sure to stall there. Where it does not, its shutdown would ask kryptikd to stop each zone and then run the system's shutdown, which syncs, stops kryptikd and unmounts: none of that can finish with the state partition suspended, and kryptikd's own power-off would die with it. sealed() reads /sys/block with builtins alone, and while it holds the service leaves the power-off to kryptikd. From 93's read: kryptikd's count of critical looks reset at each sleep's end, so a machine put back to sleep within 30 s of each wake would never see two in one wake. It now resets only once the partition is back. tools/tests/power.sh runs sealed() against stand-in device-mapper trees, and checks that the loop's power-off waits on it. --- build/service-scripts/power.sh | 14 +++++++++++++- compartments/kryptikd/src/serve.rs | 8 ++++---- docs/design/laptop.md | 11 +++++++---- tools/tests/power.sh | 17 +++++++++++++++++ 4 files changed, 41 insertions(+), 9 deletions(-) diff --git a/build/service-scripts/power.sh b/build/service-scripts/power.sh index 98c8fa39..dcfb9ffe 100755 --- a/build/service-scripts/power.sh +++ b/build/service-scripts/power.sh @@ -129,6 +129,18 @@ if [ "${1:-}" = --once ]; then exit 0 fi +# The state partition waits for its passphrase, read with builtins alone: a program started now +# can wait on it. Then kryptikd keeps the 3% rule (suspend.rs), as neither the zones' stop nor +# the shutdown's sync and unmounts could finish. +sealed() { + local d n s + for d in "$SYS"/block/dm-*/dm; do + read -r n 2>/dev/null < "$d/name" && [ "$n" = kryptik-state ] || continue + read -r s 2>/dev/null < "$d/suspended" && [ "$s" = 1 ] && return 0 + done + return 1 +} + # Each running zone stopped as `kryptikd stop` does, its launcher closing its volume, then off. shut_down() { local z @@ -158,7 +170,7 @@ while :; do fi # Twice running, so one odd reading does not end the session. if [ "$LOW" = yes ]; then low=$((low + 1)); else low=0; fi - if [ "$low" -ge 2 ]; then + if [ "$low" -ge 2 ] && ! sealed; then echo "power: the battery is at ${CHARGE}%: stopping the zones and powering off before it runs out" shut_down fi diff --git a/compartments/kryptikd/src/serve.rs b/compartments/kryptikd/src/serve.rs index 5ba5d0a8..b6853f4f 100644 --- a/compartments/kryptikd/src/serve.rs +++ b/compartments/kryptikd/src/serve.rs @@ -1987,10 +1987,11 @@ pub fn cmd_serve(zones_dir: &Path, args: &[String]) -> ExitCode { loop { if sealed_due.is_some_and(|due| Instant::now() >= due) { sealed_due = None; - // A sleep under way ends with a wake, which looks afresh. - if jobs.iter().any(|j| j.what == JobKind::Sleep) || !crate::suspend::state_suspended() { + // A sleep under way looks again when it ends, the count carried across it: a machine + // put back to sleep within 30 s of each wake still powers off. + if !crate::suspend::state_suspended() { sealed_low = 0; - } else { + } else if !jobs.iter().any(|j| j.what == JobKind::Sleep) { let (low, off) = crate::suspend::sealed_look(sealed_low, crate::suspend::critical()); sealed_low = low; sealed_due = Some(Instant::now() + SEALED_EVERY); @@ -2029,7 +2030,6 @@ pub fn cmd_serve(zones_dir: &Path, args: &[String]) -> ExitCode { // Woken or refused, a sleep leaves the state partition suspended. if slept && !cfg.developer { sealed_due = Some(Instant::now()); - sealed_low = 0; } // fds[0] is the listener, then one per launch awaiting readiness; timeout: nearest deadline. diff --git a/docs/design/laptop.md b/docs/design/laptop.md index 6c03b6fb..7fca3771 100644 --- a/docs/design/laptop.md +++ b/docs/design/laptop.md @@ -472,10 +472,13 @@ and the power key, idle and inhibitors, suspend, and runtime directories lid still shut, and run flat there. kryptikd keeps the 3% rule meanwhile, from `/sys` alone (`critical` in `suspend.rs`, held to power.sh's cases): seen twice 30 s apart, it powers the machine off at - once (`reboot(2)`). The zones are frozen and their volumes and the - partition have no key, so they are not stopped first: everything was - written out before the seal, nothing can be written now, and a sync - would wait for good. + once (`reboot(2)`). The count carries across a sleep, so a machine put + back to sleep soon after each wake still powers off. The zones are frozen + and their volumes and the partition have no key, so they are not stopped + first: everything was written out before the seal, nothing can be + written now, and a sync would wait for good. The power service, should + its programs run then, leaves the power-off to kryptikd: its shutdown + would stop the zones and sync, and neither could finish. - **A gauge that reads too low.** A worn battery whose gauge says 3% or less while it still holds charge powers the machine off a minute after every boot on battery. Requiring a reading above 3% earlier in the boot would diff --git a/tools/tests/power.sh b/tools/tests/power.sh index 49f8bf71..bfbdb944 100755 --- a/tools/tests/power.sh +++ b/tools/tests/power.sh @@ -117,5 +117,22 @@ set_attr AC online 1; echo Discharging > "$T/sys/class/power_supply/BAT0/status" once > /dev/null check "plugged in, it follows the charger even while the battery runs down" "$(pref 0)" balance_performance +echo "-- the state partition waiting for its passphrase" +# sealed() alone, from the service: it decides whether the 3% power-off is the service's or kryptikd's. +eval "$(sed -n '/^sealed() {/,/^}/p' "$P")" +dm() { # dm N NAME SUSPENDED: a device-mapper device in the stand-in /sys/block + mkdir -p "$T/sys/block/dm-$1/dm" + echo "$2" > "$T/sys/block/dm-$1/dm/name"; echo "$3" > "$T/sys/block/dm-$1/dm/suspended" +} +is_sealed() { if SYS="$T/sys" sealed; then echo yes; else echo no; fi; } +fresh +check "no device-mapper device" "$(is_sealed)" no +dm 0 kryptik-zone-work 1; dm 1 kryptik-state 0 +check "the state partition up, a zone's volume waiting" "$(is_sealed)" no +dm 1 kryptik-state 1 +check "the state partition waiting for its passphrase" "$(is_sealed)" yes +if grep -qF '[ "$low" -ge 2 ] && ! sealed; then' "$P"; then r=yes; else r=no; fi +check "then the 3% power-off is kryptikd's, not the service's" "$r" yes + printf '\n%d passed, %d failed\n' "$PASS" "$FAIL" [[ "$FAIL" -eq 0 ]] From 618389a2843291f70233422dd976a03cb56e8b0f Mon Sep 17 00:00:00 2001 From: DevomB Date: Fri, 23 Oct 2026 05:24:01 -0700 Subject: [PATCH 21/21] The report's sleep and camera lines are functions of their own laptop() had grown past the cognitive-complexity line with the wakeup sources and the AMD IOMMU line (Tectonix: complex functions 69 to 70). sleeping() and cameras() take those parts as they were, and laptop() calls them where they stood, so the report reads the same. --- tools/install/kryptik-hwreport.sh | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/tools/install/kryptik-hwreport.sh b/tools/install/kryptik-hwreport.sh index 6b41440d..5e7912db 100755 --- a/tools/install/kryptik-hwreport.sh +++ b/tools/install/kryptik-hwreport.sh @@ -422,6 +422,12 @@ laptop() { printf 'power_supply %s type %s online %s\n' "${p##*/}" "${t:--}" "${o:--}" fi done + sleeping + cameras +} + +# The sleep states the kernel offers, how the last sleeps went, and what woke the machine. +sleeping() { printf 'sleep states: %s\n' "$(one "$SYS/power/state")" printf 'sleep mem: %s\n' "$(one "$SYS/power/mem_sleep")" # The sleeps since boot, and how long the last kept the hardware in its deepest state: an @@ -460,8 +466,11 @@ laptop() { named=no; LC_ALL=C grep -aq MSFT0201 "$SYS/firmware/acpi/tables/IVRS" && named=yes printf 'amd iommu: msft0201 %s, pre-boot dma protection %s, msft0201 in ivrs %s\n' "$msft" "$dp" "$named" fi - # Intel's image processing units by PCI ID: the IPU6 and IPU7 ones 6.18's drivers bind (ipu6.h, - # staging ipu7.h), and IPU8's, which staging ipu7 names but does not bind yet. +} + +# Intel's image processing units by PCI ID: the IPU6 and IPU7 ones 6.18's drivers bind (ipu6.h, +# staging ipu7.h), and IPU8's, which staging ipu7 names but does not bind yet. +cameras() { for d in "$SYS"/bus/pci/devices/*; do [ "$(hex "$d/vendor")" = 8086 ] || continue case "$(hex "$d/device")" in