diff --git a/build/guest-tests/gui-check.sh b/build/guest-tests/gui-check.sh index d23de700..9efd270c 100755 --- a/build/guest-tests/gui-check.sh +++ b/build/guest-tests/gui-check.sh @@ -9,6 +9,7 @@ # GT KEY-MENU press Alt+p (the chrome menu) # 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 END # Verdicts: "GT PASS|FAIL|INFO name - detail". set -u @@ -336,6 +337,36 @@ n=20; while [[ "$n" -gt 0 && -n "$(questions)" ]]; do n=$((n - 1)); sleep 1; don [[ -z "$(questions)" ]] && pass "consent-cleaned" "no question left behind" || fail "consent-cleaned" "$(questions | tr ' ' ' ')" +# --- a zone's cursor image is never drawn --------------------------------------------- +# wlprobe cursor, once the pointer enters its window, asks for an image that, +# drawn, covers the screen. The compositor tells a client which output each +# of its surfaces is shown on, a cursor image among them once it is in use: +# zone 0's image is told, so the same word missing for a zone's means the +# compositor never took it. (A screenshot from the host holds no cursor.) +as_user "/usr/libexec/kryptik/wlprobe cursor 12" > "$LOG/cursor-zone0.out" 2>&1 & +probe0=$! +wait_for 20 grep -q committed "$LOG/cursor-zone0.out" +echo "GT POINTER-ZONE0" +wait_for 20 grep -q 'set a ' "$LOG/cursor-zone0.out" && pass "zone0-cursor-set" "$(grep 'set a ' "$LOG/cursor-zone0.out")" || fail "zone0-cursor-set" "$(tr '\n' ' ' < "$LOG/cursor-zone0.out")" +wait_for 10 grep -q 'the cursor image entered an output' "$LOG/cursor-zone0.out" && pass "zone0-cursor-shown" "the compositor took zone 0's image: it entered an output" || fail "zone0-cursor-shown" "zone 0's cursor image entered no output, so a zone's proves nothing: $(tr '\n' ' ' < "$LOG/cursor-zone0.out")" +wait "$probe0" 2>/dev/null +stop_zone untrusted +mark cursor untrusted +launch_plain untrusted "/usr/libexec/kryptik/wlprobe cursor 20" > "$LOG/launch-cursor.out" 2>&1 +cursor_asked() { since_mark cursor untrusted | grep -q 'set a '; } +wait_for 20 probe_committed cursor || fail "cursor-mapped" "the probe did not draw its window: $(tr '\n' ' ' < "$LOG/launch-cursor.out")" +echo "GT POINTER-UNTRUSTED" +wait_for 20 cursor_asked && pass "zone-cursor-asked" "$(since_mark cursor untrusted | grep 'set a ')" || fail "zone-cursor-asked" "$(since_mark cursor untrusted | tail -3 | tr '\n' ' '); $(zone_why untrusted)" +sleep 6 +# The zone's client hears of its window through its proxy, so it would hear of its image. +since_mark cursor untrusted | grep -q 'the window entered an output' && pass "zone-hears-of-outputs" "the zone's client was told its window entered an output" || fail "zone-hears-of-outputs" "$(since_mark cursor untrusted | tail -3 | tr '\n' ' ')" +if since_mark cursor untrusted | grep -q 'the cursor image entered an output'; then + fail "zone-cursor-not-shown" "the compositor took the zone's cursor image: it entered an output" +else + pass "zone-cursor-not-shown" "six seconds after the zone asked, its cursor image has entered no output" +fi +stop_zone untrusted + # --- teardown ------------------------------------------------------------------------------ # The runtime tmpfs does not survive power-off: copy the session log and the # last focus record to the state partition (p4, log/kryptik/) for a post-mortem. diff --git a/docs/architecture.md b/docs/architecture.md index 7a7f4586..6c913f86 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -55,6 +55,8 @@ No zone can reach the compositor's socket. A zone started from the desktop gets its own `kryptik-wlproxy`, which hides the capture, clipboard, input-injection and similar Wayland globals and stamps each window with its zone; the compositor draws the zone's border and title prefix from that. +Over a zone's window the pointer is the compositor's own cursor: an image +the zone set would be drawn above every window, wherever it chose. A window's border colour is how the user tells which zone it belongs to. If they cannot tell at a glance which zone a password prompt belongs to, the diff --git a/tools/desktop/dwl-zone-borders.py b/tools/desktop/dwl-zone-borders.py index 590a351a..958a7949 100755 --- a/tools/desktop/dwl-zone-borders.py +++ b/tools/desktop/dwl-zone-borders.py @@ -217,6 +217,27 @@ \telse \t\tfocusclient(focustop(selmon), 1); } +"""), + # setcursor: a client's cursor image is drawn above every layer, anywhere + # its hotspot puts it, so a zone's pointer shows dwl's default instead. + ("""\tstruct wlr_seat_pointer_request_set_cursor_event *event = data; +""", + """\tstruct wlr_seat_pointer_request_set_cursor_event *event = data; +\tClient *c = NULL; +"""), + ("""\tif (event->seat_client == seat->pointer_state.focused_client) +\t\twlr_cursor_set_surface(cursor, event->surface, +\t\t\t\tevent->hotspot_x, event->hotspot_y); +""", + """\tif (event->seat_client != seat->pointer_state.focused_client) +\t\treturn; +\t/* Kryptik: a zone never gets its own cursor image, which would draw over the chrome. */ +\ttoplevel_from_wlr_surface(seat->pointer_state.focused_surface, &c, NULL); +\tif (c && c->zoneborder != unzonedcolor) +\t\twlr_cursor_set_xcursor(cursor, cursor_mgr, "default"); +\telse +\t\twlr_cursor_set_surface(cursor, event->surface, +\t\t\t\tevent->hotspot_x, event->hotspot_y); """), ] diff --git a/tools/desktop/kryptik-chrome b/tools/desktop/kryptik-chrome index bf6cd219..25bb7949 100755 --- a/tools/desktop/kryptik-chrome +++ b/tools/desktop/kryptik-chrome @@ -5,7 +5,8 @@ # shows comes from the compositor and the launch daemon, never from a zone, and # dwl gives its windows the UNZONED border (build/desktop/zone-colours.h). No # zone can draw over it, read its input or capture it: the proxy passes no -# layer shell, global input or screencopy. +# layer shell, global input or screencopy, and dwl never draws a zone's +# cursor image. # # kryptik-chrome dwl's startup command (`dwl -s`): reads # dwl's status stream on stdin, records the diff --git a/tools/desktop/wlprobe.c b/tools/desktop/wlprobe.c index de27b946..4ce8dbc4 100644 --- a/tools/desktop/wlprobe.c +++ b/tools/desktop/wlprobe.c @@ -13,6 +13,12 @@ * ("oversize" by default, at most 255 bytes) * wlprobe charge map one unwritten 4 MiB shmem buffer for 10 s; * compare zone and compositor cgroup memory.current + * wlprobe cursor SECONDS as oversize 0, and when the pointer enters, set a + * cursor image twice the screen's size, hotspot in + * 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 * * Exit: 0 listed, bind accepted or window held; 3 refused (wl_display.error, * or closed); 1 any other failure. The socket is $WAYLAND_DISPLAY, absolute @@ -153,6 +159,55 @@ static void draw(void) fflush(stdout); } +/* cursor: the image is 2560x1600, for a 1280x800 screen, in a colour nothing + * else draws. */ +static const uint32_t cursor_rgb = 0x13f7a5; +static uint32_t seat_id, pointer_id, cursor_surf; +static int cursor; + +/* On every entry, as a client does; the image is made on the first. */ +static void set_cursor(uint32_t serial) +{ + int w = 2560, h = 1600, stride = w * 4, fd = -1; + size_t size = (size_t)stride * (size_t)h; + unsigned char b[24]; + if (!cursor_surf) { + fd = memfd_create("wlprobe-cursor", MFD_CLOEXEC); + if (fd < 0 || ftruncate(fd, (off_t)size) < 0) { + printf("memfd: %s\n", strerror(errno)); + if (fd >= 0) close(fd); + return; + } + uint32_t *px = mmap(NULL, size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); + if (px == MAP_FAILED) { printf("mmap: %s\n", strerror(errno)); close(fd); return; } + for (size_t i = 0; i < size / 4; i++) px[i] = cursor_rgb; + munmap(px, size); + cursor_surf = next_id++; + put32(b, cursor_surf); + send_msg(COMPOSITOR, 0, b, 4); /* wl_compositor.create_surface */ + } + put32(b, serial); put32(b + 4, cursor_surf); put32(b + 8, (uint32_t)(w / 2)); put32(b + 12, (uint32_t)(h / 2)); + send_msg(pointer_id, 0, b, 16); /* wl_pointer.set_cursor */ + if (fd >= 0) { + uint32_t pool = next_id++, buffer = next_id++; + put32(b, pool); + put32(b + 4, (uint32_t)size); + int r = send_msg_fd(SHM, 0, b, 8, fd); /* wl_shm.create_pool(id, fd, size) */ + close(fd); + if (r) { printf("create_pool: %s\n", strerror(errno)); return; } + put32(b, buffer); put32(b + 4, 0); put32(b + 8, (uint32_t)w); put32(b + 12, (uint32_t)h); + put32(b + 16, (uint32_t)stride); put32(b + 20, 1); + send_msg(pool, 0, b, 24); /* wl_shm_pool.create_buffer */ + put32(b, buffer); put32(b + 4, 0); put32(b + 8, 0); + send_msg(cursor_surf, 1, b, 12); /* wl_surface.attach */ + put32(b, 0); put32(b + 4, 0); put32(b + 8, (uint32_t)w); put32(b + 12, (uint32_t)h); + send_msg(cursor_surf, 2, b, 16); /* wl_surface.damage */ + send_msg(cursor_surf, 6, b, 0); /* wl_surface.commit */ + } + printf("set a %dx%d cursor, hotspot %d,%d\n", w, h, w / 2, h / 2); + fflush(stdout); +} + /* Consume one message if present. Returns 1 consumed, 0 need more, -1 malformed. */ static int handle_one(void) { @@ -197,6 +252,12 @@ static int handle_one(void) put32(b, get32(body)); send_msg(XDG_SURFACE, 4, b, 4); /* xdg_surface.ack_configure */ draw(); + } else if (cursor && object == pointer_id && opcode == 0) { + set_cursor(get32(body)); /* wl_pointer.enter(serial, surface, x, y) */ + } 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"); + fflush(stdout); } else if (!oversize) { printf("event object=%u opcode=%u size=%u\n", object, opcode, size); } @@ -251,6 +312,17 @@ static int hold_oversize(int more, int seconds, const char *title) send_msg(TOPLEVEL, 2, b, n); /* xdg_toplevel.set_title */ n = put_string(b, "wlprobe"); send_msg(TOPLEVEL, 3, b, n); /* xdg_toplevel.set_app_id */ + if (cursor) { + seat_id = next_id++; + if (bind_global("wl_seat", seat_id)) return 1; + pointer_id = next_id++; + put32(b, pointer_id); + send_msg(seat_id, 0, b, 4); /* wl_seat.get_pointer */ + /* A client that holds an output is told when a surface of its own + * enters it: the window when it is shown, a cursor image when the + * compositor takes it. */ + if (bind_global("wl_output", next_id++)) return 1; + } send_msg(SURFACE, 6, b, 0); /* wl_surface.commit: ask for a configure */ time_t end = time(NULL) + seconds; while (time(NULL) < end && !closed) { @@ -261,11 +333,13 @@ static int hold_oversize(int more, int seconds, const char *title) int main(int argc, char **argv) { - if (argc < 2 || (strcmp(argv[1], "list") && strcmp(argv[1], "bind") && strcmp(argv[1], "oversize") && strcmp(argv[1], "charge")) + if (argc < 2 || (strcmp(argv[1], "list") && strcmp(argv[1], "bind") && strcmp(argv[1], "oversize") && strcmp(argv[1], "charge") + && strcmp(argv[1], "cursor")) || (!strcmp(argv[1], "bind") && argc < 3) || (!strcmp(argv[1], "oversize") && argc < 4) || (!strcmp(argv[1], "oversize") && argc > 4 && strlen(argv[4]) > 255) - || (!strcmp(argv[1], "charge") && argc != 2)) { - fprintf(stderr, "usage: wlprobe list | bind INTERFACE | oversize EXTRA SECONDS [TITLE] | charge\n"); + || (!strcmp(argv[1], "charge") && argc != 2) + || (!strcmp(argv[1], "cursor") && argc != 3)) { + fprintf(stderr, "usage: wlprobe list | bind INTERFACE | oversize EXTRA SECONDS [TITLE] | charge | cursor SECONDS\n"); return 2; } const char *disp = getenv("WAYLAND_DISPLAY"); @@ -294,6 +368,7 @@ int main(int argc, char **argv) if (!strcmp(argv[1], "list")) return errored ? 3 : 0; if (!strcmp(argv[1], "oversize")) return hold_oversize(atoi(argv[2]), atoi(argv[3]), argc > 4 ? argv[4] : "oversize"); if (!strcmp(argv[1], "charge")) { charge = 1; return hold_oversize(0, 10, "shm-charge"); } + if (!strcmp(argv[1], "cursor")) { cursor = 1; return hold_oversize(0, atoi(argv[2]), "cursor"); } /* A filtered client cannot know a hidden global's name, so guess 1; the * proxy must refuse either way. */ diff --git a/tools/image/gui-test.sh b/tools/image/gui-test.sh index 4dd88c17..1c9d088d 100755 --- a/tools/image/gui-test.sh +++ b/tools/image/gui-test.sh @@ -16,6 +16,9 @@ # and the yes/no to the transfer # questions, delivered as keystrokes on the guest's keyboard, so the # trusted windows are exercised by input, not by writing answer files. +# With the pointer moved onto a zone's window, the cursor image the zone asks +# for must not be taken by the compositor; zone 0's, which is, shows the +# guest's check can tell. set -uo pipefail SELF="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=/dev/null @@ -70,6 +73,8 @@ python3 "$DRV" --serial "$SER" --qmp "$QMP" --timeout 600 \ "expect:GT KEY-FOCUS-PERSONAL" "key:alt+j" \ "type-from:GT CONSENT-CODE 1 ([0-9]+)" \ "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 END" "expect:GCHECK-DONE" \ "send:su - root -c 'poweroff'" "expect:Password: ?" "send:${RPASS}" \ "expect:Power down" "wait-exit" @@ -84,7 +89,8 @@ if [[ -n "$summary" && "${gf:-1}" -eq 0 && "${gp:-0}" -ge 25 ]]; then green "eve grep 'GT FAIL' <<<"$T" | sed 's/^/ /' for name in session-socket compositor-running chrome-focus-record chrome-window-is-zone0 zone0-sees-capture zone-proxy-path zone-sees-needed zone-hidden-globals zone-bind-refused proxy-logged-refusal \ map-keeps-zone0-focus focus-shows-zone focus-shows-label title-prefixed last-zone-recorded menu-opens-on-key menu-keeps-last-zone zone-fullscreen-refused compositor-survives-close oversize-window forged-title-named-by-zone second-zone-window zone0-own-programs-only zone-app-in-cgroup no-virtual-input clipboard-isolated clipboard-move-gesture clipboard-moved \ - transfer-policy no-question-for-policy-refusal consent-code-shown transfer-approved transfer-landed plain-y-refused denied-file-absent; do + transfer-policy no-question-for-policy-refusal consent-code-shown transfer-approved transfer-landed plain-y-refused denied-file-absent \ + zone0-cursor-set zone0-cursor-shown zone-cursor-asked zone-hears-of-outputs zone-cursor-not-shown; do grep -q "GT PASS ${name}" <<<"$T" && green "guest: ${name}" || red "guest: ${name} (not passed)" done diff --git a/tools/image/vm-drive.py b/tools/image/vm-drive.py index 330f97de..af94e10a 100755 --- a/tools/image/vm-drive.py +++ b/tools/image/vm-drive.py @@ -22,6 +22,9 @@ screendump:FILE ask QEMU (QMP) for a PPM screenshot key:NAME[+NAME...] press keys on the guest's keyboard through QMP (qcodes, e.g. key:y key:ret key:alt+e) + 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 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 @@ -273,6 +276,14 @@ def main(): r = qmp(qmpsock, "send-key", {"keys": keys, "hold-time": 80}) if "error" in r: raise RuntimeError(f"send-key {rest}: {r['error']}") time.sleep(0.3) + elif kind == "pointer": + if not qmpsock: raise RuntimeError("pointer needs --qmp") + dx, dy = (int(v) for v in rest.split(",")) + events = [{"type": "rel", "data": {"axis": "x", "value": dx}}, + {"type": "rel", "data": {"axis": "y", "value": dy}}] + 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 == "type-from": if not qmpsock: raise RuntimeError("type-from needs --qmp") text = d.line_value(rest)