Skip to content

Batch proof: the daemon review's fixes, reproducible media, the installer fixes and the release's source parts over the compositor stack (not for merging) - #600

Draft
DevomB wants to merge 215 commits into
mainfrom
proof-next-batch
Draft

DevomB wants to merge 215 commits into
mainfrom
proof-next-batch

Conversation

@DevomB

@DevomB DevomB commented Oct 16, 2026

Copy link
Copy Markdown
Owner

Not for merging: a Distro proof of the batch that follows the compositor stack. It is assembled on #585's head 0d9fb3e, so it proves the stack and the batch together. Each PR lands through its own branch once this run is green.

The batch, each at its read head:

Merge resolutions (rerere recorded):

DevomB added 30 commits October 14, 2026 23:01
… pages dwl faulted for a zone go with it

A page dwl reads before the zone writes it is charged to dwl, and a zone that
kept its descriptor kept that page after the pool was gone, which no bound on
live pools held. The proxy now keeps its own copy of each pool's descriptor and
punches the pool's range (fallocate, FALLOC_FL_PUNCH_HOLE) once the pool and
its buffers are deleted and no surface shows one. It refuses a pool it could
not punch (not open for writing, sealed against writes, not shared memory) and
seals each one it takes against a later write seal. The pools of a connection
that ended are punched a second later, once dwl has read the hang-up, and count
against the zone until then. The proxy's call list gains fstatfs and fallocate.
The pool tests use memfds, as clients do.
…y hold, so 32 clients still fit

The proxy keeps a copy of each wl_shm pool's descriptor to punch it, up to MAX_SHM_POOLS_PER_ZONE (256), and reserves them: under kryptikd's limit as it was, 32 clients' worth plus a few, it would have taken 30 clients. The limit grows by 256, and the descriptor test holds it to 32.
… two sessions still fit

The proxy now reserves 256 descriptors for the copies of its zone's pools; under the test's old limit of 300 one session fit, not two.
…le are punched by the last

pool_fd read the seals and only then added F_SEAL_SEAL, so a client
thread could add F_SEAL_FUTURE_WRITE between the two. Its mapping keeps
working under that seal, and the punch then fails with EPERM, leaving
the pages dwl faulted charged to dwl. The seal now comes first, which
makes the read final, and a failed read refuses the pool.

wl_shm.create_pool takes any descriptor, so two pools may share one
file, and punching the first to go cleared pages the other still shows.
Each pool now keeps its file's device and inode. One that goes while
another on its file remains hands that one its range and its charge,
and the last punches the widest. So a large pool released before a
small one on the same file stays counted until its pages are freed.

A punch that fails is logged through the main loop's rate-limited log.
The proxy's filter allows fstat, and fallocate only for a punch that
keeps the size.
…xt sharp at 1.5

At a fractional output scale a zone's client could only draw at the next
whole scale and have the compositor scale it down, which blurs text and
costs memory. The proxy now lets wp_fractional_scale_manager_v1 through,
and dwl's zone sockets offer it too: the compositor tells each window the
scale of its output in 120ths, and Firefox, which uses the protocol by
default, draws at that scale through wp_viewporter. It tells a zone only
its output's scale, which wl_output already gives it rounded up.

The proxy's tables are generated again with fractional-scale-v1.xml from
wayland-protocols 1.49, the others unchanged byte for byte. A proxy test
passes the request and the scale; wlprobe scale asks for it, and the
desktop suite's zone window hears it through its proxy.
…nstead of starting a second

With no remote, a second firefox in a zone whose browser is open only
says that Firefox is already running. dwl now has an action for the
browser key, focusorlaunch: it takes the zone's window whose app_id is
the proxy's kryptik.<zone>.firefox-default, matched with the zone of the
client's socket, views its first tag on its monitor when it is not shown
and gives it the keyboard, and starts the zone's Firefox only when there
is no such window. The claimed app_id is named once in dwl-config.h.

The menu keeps every launch, as a persistent zone's passphrase is asked
on its terminal. dwl's status names the zones whose browser has a window,
which the chrome records in browsers; when it names the zone, b writes
the zone's name to browser-front in the session's runtime directory
instead. dwl takes that request only from a regular file of the session
user's that no one else may write, in a directory of the user's that no
one else may write, removes it before acting, and only ever puts a window
in front. The focus record gains the window's app_id and the tags viewed.

The desktop suite presses the key from another tag: tag 3 is viewed,
Firefox has the keyboard, and no second Firefox starts. A request root
wrote is removed unheard, the user's puts Firefox in front, and with the
zone stopped the key starts Firefox.
… stamped it

The browser key matches that app_id; printed where Firefox has the
keyboard, it shows the value dwl-config.h should name whatever the later
checks find.
…nd the design names the case left

As the chrome writes it, so the check does not depend on the suite's own
umask. A press of the key or b while the zone's Firefox is starting,
before its window shows, still starts a second one. The app_id is the
program name GTK takes, which Firefox sets to its remoting name.
…s shown and has the keyboard

A zone file may say [desktop] hold_awake = true. It is off by default;
personal and work set it, untrusted sets it false beside its reason, and the
nic zone is refused it. kryptikd starts the zone's proxy with --hold-awake
from it, and only then does the proxy let zwp_idle_inhibit_manager_v1
through. The protocol tables gain idle-inhibit-unstable-v1.xml from
wayland-protocols 1.49, beside the six hashes they already name.

zone-colours.h carries the same setting for dwl, from the same files. dwl
offers the inhibitor on such a zone's socket alone, and counts one only from
such a zone, on a mapped window shown on a lit output, while a window of that
zone has the keyboard and the session is unlocked. A hold lasts four hours
from when it began or a key or button was last pressed. dwl's status names
the zone holding the screen awake, for the chrome. A keyboard focus change,
an output's power and the cap's timer each check the hold again.

Upstream dwl 4847f97 comes as patch 0003: a zone can now keep an inhibitor on
a surface whose window it unmaps, and the check reads that surface's pointer.

wlprobe gains an inhibit mode and an idle mode, and the desktop suite checks
that personal's window holds zone 0's idle timer off, that the hold ends when
a zone 0 window or the lock takes the keyboard, and that untrusted is offered
no inhibitor.
…own, and the design says when a hold begins again

Both were joined to the line before by a line continuation the edit did not mean. A hold that a lid close, a suspend or the lock ended counts again, with its four hours anew, once its window has the keyboard back after the unlock.
…unts, and the chrome names the holder

kryptik-lock --idle waited on ext-idle-notify-v1's input-only notification,
so no hold could keep the session unlocked. dwl now counts a hold only from
a zone whose file allows it, on a shown window with the keyboard, for four
hours from the last key or button press. So all three of the watcher's
timers wait on the notification that heeds inhibitors, and bind version 1,
which has it.

The chrome records dwl's holdawake line in the session's runtime directory,
as it records browsers, and the menu names the zone holding off the lock
and the screens going dark. The desktop suite checks the record during the
hold and after it ends, and that a 2 s idle lock started during the hold
is still waiting 6 s later.
…l counts

It still said the idle lock counted input alone, the protocol's version 2,
which this branch changed.
…ry zone window in the suite does

On tag 1 the chrome's zone 0 window has the keyboard, so a zone window maps behind it and gets none until the user picks it. The hold's block waits for the window to draw and has the host press Alt+j, which reaches it whether zone 0's window is there or not.
…er-focus-or-launch

# Conflicts:
#	build/guest-tests/gui-check.sh
#	tools/desktop/kryptik-chrome
#	tools/image/gui-test.sh
…ld-awake

# Conflicts:
#	compartments/kryptikd/src/zone.rs
#	compartments/kryptikd/src/zone/tests.rs
#	tools/desktop/wlprobe.c
…ake, beside the microphone's and the camera's holders

A hold's usual window is a video in front, which covers the chrome that would name the holder. The bar takes it from dwl's own count; the marks' look each second draws it again when the holder changes.
A zone name is at most 12 bytes, so the name, the microphone's, the camera's and the awake marks take 71 with the terminator; 64 cut the last.
dwl ran in the root cgroup with no limit, and a zone's pool pages that dwl reads before the zone
writes them are charged to dwl: a zone could make the compositor hold memory outside every limit.

The session's last step, kryptik-launch --desktop, asks the launch daemon to move it into
kryptik-desktop, then becomes dwl, so everything the desktop starts is inside. The group sits beside
kryptik/ rather than in it, under a memory.max of a quarter of the memory and 1 GiB at least.
The daemon moves only the asker, and refuses root, a pid another uid holds and any process in a
zone's or a proxy's cgroup. A desktop it cannot bound starts anyway, and the session log says so.

Checked by cgroup.rs's unit test, launch-desktop.py, serve.sh's S3h and gui-check's
desktop-memory-bound. launch-desktop.py checks that the launcher becomes the desktop in its own
process whatever the daemon answers.
wlprobe charge keep commits one unwritten 4 MiB buffer in untrusted, then, 4 s after it is drawn,
drops the buffer and its pool and unmaps the window while it keeps the pool's descriptor, as a zone
may. gui-check reads the desktop cgroup's memory.current before, while dwl shows the buffer, and
once the pool is gone, and prints the rises on a desktop-charge line: the measurement the design
asked for. It is what the proxy's punch will change: until the proxy punches the range, the pages
dwl faulted stay the desktop's.

tools/tests/desktop-tools.sh compiles wlprobe.c and kryptik-launch.c with the stage's hardening
flags and checks their usage, so a change to either is caught in CI before a Distro build.
A zone that outlives the desktop and keeps its pools' descriptors keeps their pages charged to the
desktop's group: they are shmem, which nothing reclaims without swap. The next login's dwl joined
that same group and could start at its bound. join_desktop now removes the group first. rmdir
succeeds on an unpopulated group whatever it still has charged, and its charges then count at the
root. EBUSY keeps a group an earlier desktop's processes still hold.
dwl-zone-borders.py makes about seventy exact-string edits to dwl 0.8, and CI checked only that they
apply as text: a C error in them showed hours into a Distro build, in stage 04.

tools/tests/dwl-compile.sh fetches the pinned wlroots, wayland-protocols and dwl, trying each source
as fetch-sources.sh does, and keeps each only with sources.lock's hash. It writes the two headers
meson would generate for wlroots and every protocol's server header, then applies
build/patches/dwl-0.8 and the zone-border edits in stage 04's order with its config. It compiles
dwl.c and util.c with dwl's own warning flags, -fsyntax-only, against the pinned headers. A
negative control, a member that does not exist, must fail. The compartment job installs the system
headers and runs it; without them it exits 77.
…dwl's filter refuses exec

The first part of the compositor's own confinement (docs/design/compositor-separation.md, step
three). Once the status command runs, dwl forks a spawner. The spawner closes everything but one
end of a socket, names itself dwl-spawner, is not dumpable and ends with dwl. It runs only the
commands in dwl-config.h's spawncmds, each as dwl's spawn did, sent to it by their index.

dwl then sets no-new-privileges and a seccomp filter that refuses execve, execveat, ptrace and
process_vm_writev, and kills calls of another architecture or ABI. A dwl made to run code reaches no
program but the table's, and cannot steer the spawner. gui-check's dwl-execs-nothing reads Seccomp: 2
and NoNewPrivs: 1 in dwl's status and finds its spawner child; every key the suite presses goes
through the spawner.
wlroots 0.19.3's version.h.in names WLR_VERSION_STR, _MAJOR, _MINOR and _MICRO with #mesondefine,
not @var@, which the placeholder check caught on its first run in CI.
Its second run in CI found the check working end to end, negative control included, and two errors
that were the host's: Kryptik's edits call wl_client_get_user_data and wl_client_set_user_data, which
libwayland 1.23 added, and Ubuntu 24.04's headers are 1.22. The script now fetches the pinned
wayland (1.26.0) as it does wlroots, writes its wayland-version.h and its core protocol's server
header, and puts its src/ before the host's include path.
DevomB added 16 commits October 16, 2026 08:25
kryptik-launch returns once the zone runs, so waiting on the launch did not wait for the probe, which keeps its window 10 s. The socket checks after it then found untrusted still running and could not start their own, and failed with no list to read.
…rd back

Killing the background job ended the shell around su, not the probe under it, so the zone 0 window stayed in front with the keyboard and hold-ends-at-lock saw none come back. The window is ended by its command, as the suite's other probes are.
… any pages the proxy left

memory.current counts dwl's own allocations too, and #565's proof showed it rising after the pool went (+2024 KiB shown, +2748 KiB gone), which says nothing either way about the punch. The pool is shmem, so the desktop cgroup's shmem in memory.stat shows its pages alone; the proxy's log says if a punch failed.
…ver name a stage

is_version took any run of letters, digits and . + - ~, so a signed statement or manifest naming the version '..' would have had staging() resolve to the update directory itself, listed names landing beside pointer and wanted. Only a signed statement can name a version, so this closes what the signature alone stood for.
…R off, queues taken

A zone could hold its launcher in close(2). It hands the broker a TCP socket whose send queue a
peer never reads, with SO_LINGER {1, INT_MAX}, and closes its own copy. The launcher's close of
the last copy then waits on the linger, relaying and reaping nothing, and the zone's cleanup
(grants revoked, the volume closed and its key wiped) waits with it.

Every descriptor recv_with_fds takes now has SO_LINGER turned off as it arrives. The kernel
closes descriptors in three more places on the receiving thread, and each is closed off:
- what does not fit the control buffer: it now has room for every descriptor a message can
  carry (253), and past twelve a request is refused with all of them released;
- descriptors sent with later bytes, which the broker read with recv(): it reads with recvmsg;
- descriptors still queued when a connection goes: the broker shuts and drains each connection
  it is done with, and the daemon each one whose request it has read.
release() also shuts and drains a Unix socket handed over before closing it, and releases
whatever was queued on it the same way.

time-offset also refuses a zone without the network before finding the floor, which can check
the release under the update chain's lock.
…zone's launcher up to a check's 60 s

Each piece is answered under the 5 s deadline, but the piece that completes the list and its signature, and the hourly newest pointer, also wait on a check of the release, which CHECK_DEADLINE bounds at 60 s.
…sockets are held while it is

An empty datagram queue answers EAGAIN even when shut, so a 0 ends the drain only on a stream,
which carries no empty message: a datagram socket's empty datagrams no longer stop the drain
ahead of the descriptors behind them. A seqpacket's 0 may be an empty message or its end, so it
keeps the bound of 1024 messages. Descriptors taken from a queue that are not Unix sockets close
at once, already defused, so only the sockets whose own queues are still to take are held.
…keeps it

GNU coreutils installs chroot in /usr/sbin, and uutils, Ubuntu 26.04's coreutils, in /usr/bin. The check named /usr/sbin/chroot, so on 26.04 the zone's shell found nothing to run and exited 127 where the filter's kill gives 159.
Back from tty2, dwl answers clients as soon as its seat is enabled again, and libinput reopens the keyboards through the seat a moment later. The single Alt+p the host sends on GT KEY-MENU-AFTER-VT could arrive in between and be lost, as in the compositor proof's run (menu windows: 1 before Alt+p, 1 after). The check now waits until dwl holds as many /dev/input/event descriptors as before the switch, says so when they do not come back, and names both counts if no menu opens.
DevomB added 13 commits October 16, 2026 14:31
# Conflicts:
#	compartments/kryptikd/src/serve/tests.rs
…t medium

Stage 06 signs the two image lists the guest must refuse, one in the release manifests' namespace and one for another root, only for a development build, since a production release key signs real lists alone. The guest still ran both checks on a production medium, where keep_list found nothing and the checks failed on the previous zone's result, and zones-test.sh required both. Acceptance's zones part would so have failed on every production build: the v1.0.0 tag, and a role=production rehearsal. The guest now runs them on a development medium and says why it skips them on a production one; the suite takes the role from the payload's manifest, as the update suite does for its controls.
… a keyboard added after it confines itself takes keys

wlroots 0.19.3 gives each keyboard its keymap in a file made by shm_open
under /dev/shm. dwl's Landlock rules refused that create, so a keyboard
libinput added after dwl confined itself had no keymap. wlroots then left
it out of dwl's keyboard group, and its keys reached nothing. This covers
the switch back from the administration console, a keyboard plugged in
after login, and one found again after a sleep.

The rule beneath /dev/shm grants what that path does: create, open for
reading and writing, open again read-only, truncate and unlink.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants