NetSurf: what GTK's images and libcurl's resolver needed

NetSurf (GTK 3) now starts under X and loads pages over HTTPS. Every icon
GTK draws goes gdk-pixbuf -> glycin -> a loader process -> a sealed memfd
-> GMappedFile, and each hop found something missing:

- mkaroot runs Alpine's package triggers in the root under bubblewrap.
  --no-scripts had skipped them, so there was no MIME cache, pixbuf loader
  list, compiled GSettings schemas, GIO module cache, font cache or busybox
  applet links.
- /usr/bin/bwrap is a stand-in that parses bubblewrap's options and runs
  the program with no sandbox: glycin starts its loaders only through bwrap,
  and real bwrap needs namespaces. schos-base replaces bubblewrap for it.
- ioctl: FIOCLEX, FIONCLEX, FIONBIO and FIOASYNC on any descriptor (Rust's
  std uses them; pipes and sockets answered ENOTTY).
- capget/capset: root has every capability, others none.
- socketpair accepts SOCK_DGRAM and SOCK_SEQPACKET, carried as a stream.
- waitpid and setpgid match children against the thread group leader, so a
  thread can wait for the process it spawned (ECHILD before).
- memfd: fstat reports the ftruncate length, not the page-rounded one; file
  seals (F_ADD_SEALS, F_GET_SEALS, MFD_ALLOW_SEALING) are recorded and
  enforced on later writes, truncation and writable mappings; MFD_CLOEXEC
  is honoured; read-only MAP_PRIVATE maps are allowed.
- recvfrom honours O_NONBLOCK and MSG_DONTWAIT. musl's resolver reads its
  socket until EAGAIN and blocked for ever after the first DNS answer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1giNvrHTrPMPw7V6x1kQ4
This commit is contained in:
Nexoniarz 2026-10-25 04:10:26 +01:00
commit 393bf674f5
11 changed files with 251 additions and 21 deletions

View file

@ -1477,18 +1477,21 @@ waitpid(int pid, int *status, int options, uint64 *ut, uint64 *st)
{
struct proc *p;
int havekids;
// Children belong to the process, not to the thread that made them: any
// thread may wait for them, and their exit wakes the leader's channel.
struct proc *me = proc_leader(myproc());
acquire(&ptable.lock);
for(;;){
havekids = 0;
for(p = ptable.proc; p < &ptable.proc[NPROC]; p++){
if(p->parent != myproc())
if(p->parent != me)
continue;
// POSIX pid selection: >0 one child, 0 = caller's group,
// -1 = any child, < -1 = the process group with id -pid.
if(pid > 0 && p->pid != pid)
continue;
if(pid == 0 && p->pgid != myproc()->pgid)
if(pid == 0 && p->pgid != me->pgid)
continue;
if(pid < -1 && p->pgid != -pid)
continue;
@ -1503,8 +1506,8 @@ waitpid(int pid, int *status, int options, uint64 *ut, uint64 *st)
// RUSAGE_CHILDREN: the child's own time *and* whatever it had already
// reaped, which is what makes the total transitive down a whole tree
// of processes rather than one generation deep.
myproc()->cutime += p->utime + p->cutime;
myproc()->cstime += p->stime + p->cstime;
me->cutime += p->utime + p->cutime;
me->cstime += p->stime + p->cstime;
reaped_utime += p->utime;
reaped_stime += p->stime;
// wait4()'s fourth argument wants the *child's* usage, not the
@ -1576,7 +1579,7 @@ waitpid(int pid, int *status, int options, uint64 *ut, uint64 *st)
return 0;
}
sleep(myproc(), &ptable.lock);
sleep(me, &ptable.lock);
}
}
@ -2699,7 +2702,7 @@ proc_setpgid(int pid, int pgid)
continue;
// A caller may only move itself or one of its own children, and never
// across a session boundary.
if(p != myproc() && p->parent != myproc())
if(p != myproc() && p->parent != proc_leader(myproc()))
r = -ESRCH;
else if(p->sid != myproc()->sid)
r = -EPERM;

View file

@ -482,6 +482,13 @@ sys_fcntl(void)
case F_SETFL:
f->nonblock = (arg & O_NONBLOCK) != 0;
return 0;
case F_ADD_SEALS:
case F_GET_SEALS:
if (f->type != FD_MEMFD)
return -EINVAL;
if (cmd == F_ADD_SEALS && !f->writable)
return -EPERM;
return memfd_seals(f->memfd_seg, cmd == F_ADD_SEALS ? arg : -1);
case F_GETLK: case F_SETLK: case F_SETLKW:
case F_OFD_GETLK: case F_OFD_SETLK: case F_OFD_SETLKW: {
// A pointer, which argint() above would have cut to 32 bits.
@ -1547,6 +1554,28 @@ addr_t sys_ioctl(void)
return 0;
}
// The four that apply to any descriptor at all. Rust's standard library
// sets close-on-exec with FIOCLEX and non-blocking with FIONBIO rather
// than with fcntl, and on a pipe or a socket those fell through to the
// ENOTTY below: glycin's image loaders failed with "Not a tty" and GTK
// could load no icon.
if (request == 0x5451 || request == 0x5450) { // FIOCLEX, FIONCLEX
if (request == 0x5451)
myproc()->files->flags[fd] |= FD_CLOEXEC;
else
myproc()->files->flags[fd] &= ~FD_CLOEXEC;
return 0;
}
if (request == 0x5421) { // FIONBIO
int on;
if (copy_from_user(&on, argp, sizeof(on)) < 0)
return -EFAULT;
f->nonblock = on != 0;
return 0;
}
if (request == 0x5452) // FIOASYNC
return 0;
// /dev/fb0 (major 2) — geometry queries. This has to come before the
// catch-all below, which answers every non-tty ioctl with 0: a program
// asking FBIOGET_VSCREENINFO would have been told "success" and then

View file

@ -3,6 +3,7 @@
#include "gfx/gfx.h"
#include "core/param.h"
#include "uapi/stat.h"
#include "uapi/fcntl.h"
#include "arch/memlayout.h"
#include "arch/mmu.h"
#include "core/spinlock.h"
@ -272,15 +273,21 @@ mmap_locked(void)
// process that maps the same segment gets the same physical pages, and a
// store by one is a load by the other because there is only one copy.
//
// MAP_PRIVATE is refused rather than given a copy. Every caller of this is
// sharing on purpose, and a private mapping of an anonymous object is an
// expensive way to ask for MAP_ANONYMOUS.
// A private mapping is allowed read-only, and is then the same pages
// mapped without write access: until somebody writes, a private mapping
// sees the object's stores on Linux too. GLib's GMappedFile maps a memfd
// this way, and that is how gdk-pixbuf reads every image glycin decodes.
// Private and writable would need a copy that is not made; it is refused.
if (f && f->type == FD_MEMFD) {
int id = f->memfd_seg;
uint64 have = memfd_size(id);
uint64 have = PGROUNDUP(memfd_size(id)); // the tail page maps whole
if (!(flags & MAP_SHARED))
if (!(flags & MAP_SHARED) && (prot & PROT_WRITE))
return -EINVAL;
int pte = (flags & MAP_SHARED) ? PTE_W | PTE_U : PTE_U;
if ((prot & PROT_WRITE) &&
(memfd_seals(id, -1) & (F_SEAL_WRITE | F_SEAL_FUTURE_WRITE)))
return -EPERM;
if (offset % PGSIZE)
return -EINVAL;
// Mapping past the end is not an error on Linux -- the pages simply
@ -295,7 +302,7 @@ mmap_locked(void)
for (uint64 o = 0; o < length; o += PGSIZE) {
char *pg = shm_page(id, (int)((offset + o) / PGSIZE));
if (!pg || mappages(myproc()->pgdir, (void*)(va + o), PGSIZE,
V2P((uint64)pg), PTE_W | PTE_U) < 0) {
V2P((uint64)pg), pte) < 0) {
vm_unmap_norelease(myproc()->pgdir, va, o);
shm_detach(id);
return -ENOMEM;
@ -517,6 +524,8 @@ struct shmseg {
int used;
int key;
uint64 size; // rounded up to a page
uint64 bytes; // a memfd's length as ftruncate gave it
int seals; // a memfd's F_SEAL_* bits
int npages;
int nattach; // live attachments across all processes
char *pages[SHM_MAXPAGES]; // kernel virtual addresses
@ -605,6 +614,7 @@ shm_resize(int id, uint64 newsize)
{
struct shmseg *sg;
int want, i;
uint64 len = newsize;
if (id < 0 || id >= NSHM)
return -EINVAL;
@ -644,6 +654,7 @@ shm_resize(int id, uint64 newsize)
acquire(&shmlock);
sg->npages = want;
sg->size = newsize;
sg->bytes = len;
release(&shmlock);
return 0;
}
@ -671,7 +682,11 @@ memfd_size(int id)
return 0;
shm_init_once();
acquire(&shmlock);
n = shm[id].used ? shm[id].size : 0;
// The length asked for, not the pages behind it. fstat reports this, and
// memmap2 -- which glycin uses to share every decoded image -- sizes its
// mapping by it: 24x24 icons came back as 4096 bytes instead of 2304, and
// the PNG decoder asserted.
n = shm[id].used ? shm[id].bytes : 0;
release(&shmlock);
return n;
}
@ -679,9 +694,47 @@ memfd_size(int id)
int
memfd_truncate(int id, uint64 len)
{
if (id < 0 || id >= NSHM)
return -EINVAL;
uint64 now = memfd_size(id);
if ((len < now && (shm[id].seals & F_SEAL_SHRINK)) ||
(len > now && (shm[id].seals & F_SEAL_GROW)))
return -EPERM;
return shm_resize(id, len);
}
// File seals: promises about a memfd that whoever receives it can rely on.
// glycin seals each decoded image before handing it over, and gdk-pixbuf
// first asks for the seals just to learn the descriptor is a memfd at all.
//
// Linux refuses F_SEAL_WRITE while a writable mapping exists (EBUSY). That
// needs every mapping of the segment to be made read-only on the spot, which
// takes the page fault handling this kernel does not have, so the seal is
// recorded and later writes, truncates and writable mappings are refused --
// the mapping that already exists keeps writing.
int
memfd_seals(int id, int add)
{
int r;
if (id < 0 || id >= NSHM)
return -EINVAL;
acquire(&shmlock);
if (!shm[id].used)
r = -EINVAL;
else if (add < 0)
r = shm[id].seals;
else if (shm[id].seals & F_SEAL_SEAL)
r = -EPERM;
else if (add & ~(F_SEAL_SEAL | F_SEAL_SHRINK | F_SEAL_GROW | F_SEAL_WRITE | F_SEAL_FUTURE_WRITE))
r = -EINVAL;
else {
shm[id].seals |= add;
r = 0;
}
release(&shmlock);
return r;
}
// read()/write() on a memfd, so it is a file rather than a token that only
// mmap understands. Wayland clients use ftruncate and mmap, but a descriptor
// that refuses read() is a surprise nobody needs.
@ -697,10 +750,12 @@ memfd_rw(int id, int user, uint64 buf, uint64 off, int n, int writing)
sg = &shm[id];
if (!sg->used)
return -EINVAL;
if (off >= sg->size)
if (writing && (sg->seals & (F_SEAL_WRITE | F_SEAL_FUTURE_WRITE)))
return -EPERM;
if (off >= sg->bytes)
return 0; // EOF, or a write past the end
if (off + (uint64)n > sg->size)
n = (int)(sg->size - off);
if (off + (uint64)n > sg->bytes)
n = (int)(sg->bytes - off);
while (done < n) {
uint64 pos = off + (uint64)done;
@ -751,6 +806,8 @@ sys_memfd_create(void)
shm[i].size = 0;
shm[i].npages = 0;
shm[i].nattach = 1; // the file holds this one
// Without MFD_ALLOW_SEALING the object is sealed against sealing.
shm[i].seals = (flags & MFD_ALLOW_SEALING) ? 0 : F_SEAL_SEAL;
id = i;
break;
}
@ -761,6 +818,8 @@ sys_memfd_create(void)
if ((f = filealloc()) == 0) { shm_detach(id); return -ENFILE; }
if ((fd = fdalloc(f)) < 0) { fileclose(f); return -EMFILE; }
if (flags & MFD_CLOEXEC)
myproc()->files->flags[fd] = FD_CLOEXEC;
f->type = FD_MEMFD;
f->memfd_seg = id;
f->readable = 1;

View file

@ -459,7 +459,12 @@ SYSCALL_DEFINE(socketpair)
return -EAFNOSUPPORT;
int sflags = type & (SOCK_CLOEXEC | SOCK_NONBLOCK);
type &= 0xFF;
if (type != SOCK_STREAM)
// Datagram and sequenced-packet pairs are carried as a stream. What they
// are used for between two processes is passing a descriptor or a status
// word -- Rust's process spawning asks for SOCK_SEQPACKET to hear back
// from its child -- and one message at a time is all of that. A reader
// that sends two and wants them kept apart would see them joined.
if (type != SOCK_STREAM && type != 2 /* DGRAM */ && type != 5 /* SEQPACKET */)
return -EPROTOTYPE;
if ((r = sockpair(&a, &b)) < 0)
@ -532,15 +537,18 @@ SYSCALL_DEFINE(sendto)
return socksendto(s, p, len, ip, port);
}
static int argsockfile(int n, struct socket **sp, struct file **fp);
SYSCALL_DEFINE(recvfrom)
{
struct socket *s;
struct file *file;
addr_t ubuf, uaddr, ulenp;
int len, flags, r;
uint ip = 0;
ushort port = 0;
if ((r = argsock(0, &s)) < 0)
if ((r = argsockfile(0, &s, &file)) < 0)
return r;
argaddr(1, &ubuf);
argint(2, &len);
@ -554,6 +562,13 @@ SYSCALL_DEFINE(recvfrom)
if (len && !p)
return -EFAULT;
// As in recvmsg. This one blocked regardless, and musl's resolver -- which
// sends the A and AAAA queries together and reads its non-blocking socket
// until EAGAIN -- waited for ever after the first answer: NetSurf sat on
// "Loading" with the reply in hand.
if ((file->nonblock || (flags & MSG_DONTWAIT)) && !sockreadable(s))
return -EAGAIN;
r = sockrecvfrom(s, p, len, &ip, &port);
if (r < 0 || uaddr == 0)
return r;

View file

@ -134,6 +134,39 @@ sys_wait4(void)
return wpid;
}
// Capabilities: all of them for root, none for anyone else -- which is what
// the permission checks here amount to. glycin asks before it sandboxes an
// image loader, and stopped at ENOSYS.
addr_t
sys_capget(void)
{
addr_t hdrp, datap;
uint32 hdr[2];
argaddr(0, &hdrp);
argaddr(1, &datap);
if (copy_from_user(hdr, hdrp, sizeof(hdr)) < 0)
return -EFAULT;
if (hdr[0] != 0x19980330 && hdr[0] != 0x20071026 && hdr[0] != 0x20080522) {
hdr[0] = 0x20080522; // the version this kernel speaks
put_user(hdr[0], hdrp);
return -EINVAL;
}
if (datap == 0)
return 0;
int words = hdr[0] == 0x19980330 ? 1 : 2;
uint32 all = myproc()->euid == 0 ? 0xffffffffu : 0;
for (int i = 0; i < words; i++) {
uint32 d[3] = { all, all, 0 }; // effective, permitted, inheritable
if (copy_to_user(datap + i * sizeof(d), d, sizeof(d)) < 0)
return -EFAULT;
}
return 0;
}
// Dropping capabilities is accepted and changes nothing: there are none to
// drop that the uid does not already decide.
addr_t sys_capset(void) { return 0; }
addr_t
sys_clone(void)
{

View file

@ -298,6 +298,8 @@ SYSCALL(205, 284, eventfd)
SYSCALL(206, 73, flock)
SYSCALL(207, 51, getsockname)
SYSCALL(208, 52, getpeername)
SYSCALL(209, 125, capget)
SYSCALL(210, 126, capset)
// --- mounts ---
SYSCALL(185, 165, mount)