//go:build xserver package xtest import ( "bytes" "encoding/binary" "encoding/hex" "encoding/json" "flag" "fmt" "io" "maps" "net" "os" "os/exec" "path/filepath" "slices" "strconv" "strings" "testing" "time" "golang.org/x/sys/unix" ) var update = flag.Bool("update", false, "rewrite wire goldens from Xvfb instead of comparing against them") // field is a server-dependent value inside a golden: the size-byte // little-endian integer at Off must equal the server's value for Role. type field struct { Off, Size int Role string } // serverFields lists, per golden, every value the X server chooses: resource // IDs derived from a server-assigned base, the root window and its visual and // colormap, interned atoms, extension opcodes and bases, and timestamps. // Compare mode checks that the golden holds the value recorded at capture // (xvfb-roles.json) and the live packet holds the live server's value, then // masks both and compares the remaining bytes exactly. Role "time" is a // per-run server timestamp and is masked without a value check. var serverFields = map[string][]field{ "xproto/MapWindow.req": {{4, 4, "root"}}, "xproto/GetGeometry.req": {{4, 4, "root"}}, "xproto/ChangeProperty.req": {{4, 4, "root"}, {8, 4, "atom"}}, "xproto/CreateWindow.req": {{4, 4, "child"}, {8, 4, "root"}}, "xproto/ChangeWindowAttributes.req": {{4, 4, "child"}}, "xproto/ConfigureWindow.req": {{4, 4, "child"}}, "xproto/ConfigureWindowSparse.req": {{4, 4, "child"}}, "xproto/ConfigureWindowAll.req": {{4, 4, "child"}, {32, 4, "sibling"}}, "render/CreatePicture.req": {{0, 1, "renderMajor"}, {4, 4, "picture"}, {8, 4, "child"}, {12, 4, "rootFormat"}}, "res/QueryClientIds.req": {{0, 1, "resMajor"}}, "xfixes/QueryVersion.req": {{0, 1, "xfixesMajor"}}, "xproto/QueryExtension.reply": {{9, 1, "xfixesMajor"}, {10, 1, "xfixesEvent"}, {11, 1, "xfixesError"}}, "xproto/GetGeometry.reply": {{8, 4, "root"}}, "xproto/InternAtom.reply": {{8, 4, "atom"}}, "xproto/GetWindowAttributes.reply": {{8, 4, "rootVisual"}, {28, 4, "rootColormap"}}, "xproto/QueryTree.reply": {{8, 4, "root"}, {32, 4, "sibling"}, {36, 4, "child"}}, "res/QueryClientIds.reply": {{44, 4, "base"}}, "xproto/CreateNotify.event": {{4, 4, "root"}, {8, 4, "client2"}}, "xproto/MapRequest.event": {{4, 4, "root"}, {8, 4, "client2"}}, "xproto/ConfigureRequest.event": {{4, 4, "root"}, {8, 4, "client2"}}, "xproto/MapNotify.event": {{4, 4, "root"}, {8, 4, "client2"}}, "xproto/UnmapNotify.event": {{4, 4, "root"}, {8, 4, "client2"}}, "xproto/DestroyNotify.event": {{4, 4, "root"}, {8, 4, "client2"}}, "xproto/PropertyNotify.event": {{4, 4, "root"}, {8, 4, "atom"}, {12, 4, "time"}}, "xproto/ClientMessage.event": {{4, 4, "client2"}, {8, 4, "atom"}}, "xproto/SelectionNotify.event": {{8, 4, "child"}}, "xfixes/SelectionNotify.event": {{0, 1, "xfixesEvent"}, {4, 4, "root"}, {8, 4, "client2"}, {16, 4, "time"}, {20, 4, "time"}}, "xproto/BadWindow.error": {{4, 4, "badWindow"}}, "xfixes/BadRegion.error": {{1, 1, "xfixesError"}, {4, 4, "badRegion"}, {10, 1, "xfixesMajor"}}, } // structural replies depend on the server build itself (extension list, // visuals and picture formats), so live compare checks invariants instead of // bytes; unit tests still decode the committed golden byte-exactly. var structural = map[string]func(p []byte, roles map[string]uint32) error{ "xproto/ListExtensions.reply": checkListExtensions, "render/QueryPictFormats.reply": checkQueryPictFormats, } const rolesPath = "../../testdata/golden/xvfb-roles.json" // xvfbArgs lets a run simulate a different server layout, e.g. // XTURBO_XVFB_ARGS="-extension GLX" shifts resource IDs, atoms and visuals. func xvfbArgs() []string { args := []string{"-displayfd", "3", "-screen", "0", "800x600x24", "-nolisten", "tcp", "-ac"} return append(args, strings.Fields(os.Getenv("XTURBO_XVFB_ARGS"))...) } // TestCaptureRequests drives a fresh Xvfb through a raw socketpair tee. By // default it compares every captured packet with the committed golden and // fails on a difference; -update rewrites the goldens instead. func TestCaptureRequests(t *testing.T) { if _, err := exec.LookPath("Xvfb"); err != nil { t.Skip("Xvfb not installed") } // displayfd avoids display-number races and tells us when Xvfb is ready. r, w, err := os.Pipe() if err != nil { t.Fatal(err) } // Xvfb writes to displayfd again after a server reset; keep the read end // open until the test ends so that write cannot kill the server. t.Cleanup(func() { _ = r.Close() }) cmd := exec.Command("Xvfb", xvfbArgs()...) cmd.ExtraFiles = []*os.File{w} var log bytes.Buffer cmd.Stderr = &log if err = cmd.Start(); err != nil { w.Close() t.Fatal(err) } w.Close() defer func() { _ = cmd.Process.Kill(); _ = cmd.Wait() }() _ = r.SetReadDeadline(time.Now().Add(5 * time.Second)) var display [32]byte n, err := r.Read(display[:]) if err != nil { t.Fatalf("Xvfb: %v: %s", err, log.String()) } num, err := strconv.Atoi(strings.TrimSpace(string(display[:n]))) if err != nil { t.Fatal(err) } server, err := net.DialTimeout("unix", fmt.Sprintf("/tmp/.X11-unix/X%d", num), 5*time.Second) if err != nil { t.Fatal(err) } defer server.Close() fds, err := unix.Socketpair(unix.AF_UNIX, unix.SOCK_STREAM|unix.SOCK_CLOEXEC, 0) if err != nil { t.Fatal(err) } a := os.NewFile(uintptr(fds[0]), "capture-client") b := os.NewFile(uintptr(fds[1]), "capture-tee") client, err := net.FileConn(a) a.Close() if err != nil { b.Close() t.Fatal(err) } defer client.Close() tee, err := net.FileConn(b) b.Close() if err != nil { t.Fatal(err) } defer tee.Close() _ = client.SetDeadline(time.Now().Add(5 * time.Second)) _ = server.SetDeadline(time.Now().Add(5 * time.Second)) var recorded bytes.Buffer copied := make(chan error, 1) go func() { _, err := io.Copy(server, io.TeeReader(tee, &recorded)); copied <- err }() go func() { _, _ = io.Copy(tee, server) }() write := func(p []byte) { t.Helper() if _, err := client.Write(p); err != nil { t.Fatal(err) } } read := func(p []byte) { t.Helper() if _, err := io.ReadFull(client, p); err != nil { t.Fatal(err) } } // Independent reference encoder: no generated bindings participate in capture. write([]byte{'l', 0, 11, 0, 0, 0, 0, 0, 0, 0, 0, 0}) var setupHead [8]byte read(setupHead[:]) if setupHead[0] != 1 { t.Fatalf("setup failed: %x", setupHead) } setup := make([]byte, int(binary.LittleEndian.Uint16(setupHead[6:]))*4) read(setup) vendorLen := int(binary.LittleEndian.Uint16(setup[16:])) formats := int(setup[21]) screenOff := 32 + (vendorLen+3)&^3 + 8*formats root := binary.LittleEndian.Uint32(setup[screenOff:]) // Query XFIXES dynamically rather than assuming a server-specific major opcode. query := []byte{98, 0, 4, 0, 6, 0, 0, 0, 'X', 'F', 'I', 'X', 'E', 'S', 0, 0} write(query) // Server bytes reach the client through the same tee; keep named replies as // reply goldens (reply byte 0 through 32+4*length). replies := map[string][]byte{} keep := func(name string, p []byte) []byte { t.Helper() if p[0] != 1 { t.Fatalf("%s: expected a reply, got %x", name, p) } replies[name] = p return p } packet := func() []byte { t.Helper() head := make([]byte, 32) read(head) if head[0] == 1 { tail := make([]byte, int(binary.LittleEndian.Uint32(head[4:]))*4) read(tail) head = append(head, tail...) } return head } ext := keep("xproto/QueryExtension", packet()) if ext[0] != 1 || ext[8] != 1 { t.Fatalf("XFIXES unavailable: %x", ext) } major := ext[9] version := make([]byte, 12) version[0] = major version[2] = 3 binary.LittleEndian.PutUint32(version[4:], 6) write(version) reply := keep("xfixes/QueryVersion", packet()) if reply[0] != 1 { t.Fatalf("QueryVersion failed: %x", reply) } mapReq := make([]byte, 8) mapReq[0] = 8 mapReq[2] = 2 binary.LittleEndian.PutUint32(mapReq[4:], root) write(mapReq) geometry := append([]byte(nil), mapReq...) geometry[0] = 14 write(geometry) geom := keep("xproto/GetGeometry", packet()) if geom[0] != 1 || binary.LittleEndian.Uint16(geom[2:]) != 4 { t.Fatalf("MapWindow/GetGeometry rejected: %x", geom) } // InternAtom, ChangeProperty and XRes QueryClientIds exercise derived lengths, // byte expressions and fixed-struct lists with independently assembled bytes. intern := []byte{16, 0, 4, 0, 7, 0, 0, 0, 'X', 'T', 'U', 'R', 'B', 'O', '_', 0} write(intern) atomReply := keep("xproto/InternAtom", packet()) if atomReply[0] != 1 { t.Fatalf("InternAtom: %x", atomReply) } atom := binary.LittleEndian.Uint32(atomReply[8:]) property := make([]byte, 28) property[0] = 18 property[2] = 7 binary.LittleEndian.PutUint32(property[4:], root) binary.LittleEndian.PutUint32(property[8:], atom) binary.LittleEndian.PutUint32(property[12:], 31) property[16] = 8 binary.LittleEndian.PutUint32(property[20:], 3) copy(property[24:], "abc") write(property) // A successful GetProperty reply proves the preceding write was accepted. get := make([]byte, 24) get[0] = 20 get[2] = 6 binary.LittleEndian.PutUint32(get[4:], root) binary.LittleEndian.PutUint32(get[8:], atom) binary.LittleEndian.PutUint32(get[20:], 1) write(get) got := keep("xproto/GetProperty", packet()) if got[0] != 1 || string(got[32:35]) != "abc" { t.Fatalf("ChangeProperty: %x", got) } resName := "X-Resource" queryRes := make([]byte, 20) queryRes[0] = 98 queryRes[2] = 5 queryRes[4] = byte(len(resName)) copy(queryRes[8:], resName) write(queryRes) resExt := packet() if resExt[0] != 1 || resExt[8] != 1 { t.Fatalf("XRes unavailable: %x", resExt) } resMajor := resExt[9] resVersion := []byte{resMajor, 0, 2, 0, 1, 2, 0, 0} write(resVersion) if p := packet(); p[0] != 1 { t.Fatalf("XRes version: %x", p) } ids := make([]byte, 16) ids[0] = resMajor ids[1] = 4 ids[2] = 4 binary.LittleEndian.PutUint32(ids[4:], 1) binary.LittleEndian.PutUint32(ids[12:], 1) write(ids) keep("res/QueryClientIds", packet()) // Value-list reference packets are assembled independently, including mask-bit // ordering. Two sibling windows allow all seven ConfigureWindow bits to be // exercised legally. A final reply fences all void requests and exposes errors. base := binary.LittleEndian.Uint32(setup[4:]) child, sibling, picture := base|1, base|2, base|3 create := func(id uint32, mask uint32, values ...uint32) { p := make([]byte, 32+4*len(values)) p[0] = 1 binary.LittleEndian.PutUint16(p[2:], uint16(len(p)/4)) binary.LittleEndian.PutUint32(p[4:], id) binary.LittleEndian.PutUint32(p[8:], root) binary.LittleEndian.PutUint16(p[16:], 64) binary.LittleEndian.PutUint16(p[18:], 48) binary.LittleEndian.PutUint16(p[22:], 1) // InputOutput, inherited depth/visual binary.LittleEndian.PutUint32(p[28:], mask) for i, v := range values { binary.LittleEndian.PutUint32(p[32+4*i:], v) } write(p) } create(sibling, 0) create(child, 2|512|1024, 0x123456, 1, 0) change := make([]byte, 20) change[0], change[2] = 2, 5 binary.LittleEndian.PutUint32(change[4:], child) binary.LittleEndian.PutUint32(change[8:], 512|1024) binary.LittleEndian.PutUint32(change[16:], 1) // false, true BOOL32 words write(change) configure := func(mask uint16, values ...uint32) { p := make([]byte, 12+4*len(values)) p[0] = 12 binary.LittleEndian.PutUint16(p[2:], uint16(len(p)/4)) binary.LittleEndian.PutUint32(p[4:], child) binary.LittleEndian.PutUint16(p[8:], mask) for i, v := range values { binary.LittleEndian.PutUint32(p[12+4*i:], v) } write(p) } configure(0) configure(1|4|64, 0xfffffff9, 80, 0) configure(127, 0xfffffffd, 9, 96, 72, 2, sibling, 0) queryRender := []byte{98, 0, 4, 0, 6, 0, 0, 0, 'R', 'E', 'N', 'D', 'E', 'R', 0, 0} write(queryRender) renderExt := packet() if renderExt[0] != 1 || renderExt[8] != 1 { t.Fatalf("value requests/RENDER query: %x", renderExt) } renderMajor := renderExt[9] write([]byte{renderMajor, 1, 1, 0}) // QueryPictFormats formatsReply := keep("render/QueryPictFormats", packet()) if formatsReply[0] != 1 { t.Fatalf("QueryPictFormats: %x", formatsReply) } // Match the root visual rather than guessing a server-assigned format ID. rootVisual := binary.LittleEndian.Uint32(setup[screenOff+32:]) off := 32 + 28*int(binary.LittleEndian.Uint32(formatsReply[8:])) var format uint32 for range binary.LittleEndian.Uint32(formatsReply[12:]) { depths := binary.LittleEndian.Uint32(formatsReply[off:]) off += 8 for range depths { visuals := binary.LittleEndian.Uint16(formatsReply[off+2:]) off += 8 for range visuals { if binary.LittleEndian.Uint32(formatsReply[off:]) == rootVisual { format = binary.LittleEndian.Uint32(formatsReply[off+4:]) } off += 8 } } } if format == 0 { t.Fatal("root visual has no Render format") } pic := make([]byte, 32) pic[0], pic[1], pic[2] = renderMajor, 4, 8 binary.LittleEndian.PutUint32(pic[4:], picture) binary.LittleEndian.PutUint32(pic[8:], child) binary.LittleEndian.PutUint32(pic[12:], format) binary.LittleEndian.PutUint32(pic[16:], 1|16|32) binary.LittleEndian.PutUint32(pic[20:], 1) // RepeatNormal binary.LittleEndian.PutUint32(pic[24:], 0xfffffffb) binary.LittleEndian.PutUint32(pic[28:], 7) write(pic) write([]byte{43, 0, 1, 0}) // GetInputFocus fences CreatePicture if p := packet(); p[0] != 1 { t.Fatalf("CreatePicture rejected: %x", p) } // Reply-only captures. Opcodes 3, 15, 20 and 99 are not request goldens, so // the request-stream classifier below is unaffected. attrs := make([]byte, 8) attrs[0], attrs[2] = 3, 2 binary.LittleEndian.PutUint32(attrs[4:], child) write(attrs) keep("xproto/GetWindowAttributes", packet()) tree := make([]byte, 8) tree[0], tree[2] = 15, 2 binary.LittleEndian.PutUint32(tree[4:], root) write(tree) keep("xproto/QueryTree", packet()) // WM_NAME is unset on a fresh Xvfb root: a zero-length GetProperty reply. empty := make([]byte, 24) empty[0], empty[2] = 20, 6 binary.LittleEndian.PutUint32(empty[4:], root) binary.LittleEndian.PutUint32(empty[8:], 39) binary.LittleEndian.PutUint32(empty[20:], 1) write(empty) keep("xproto/GetPropertyEmpty", packet()) write([]byte{99, 0, 1, 0}) // ListExtensions: variable STR iterator keep("xproto/ListExtensions", packet()) // Event and error captures. The tee client acts as a window manager on the // root; a second raw client (direct socket, not teed) triggers the events. // Requests sent here on the tee use window/minor values the request-golden // classifier below ignores. xfixesEvent, xfixesError := ext[10], ext[11] events := map[string][]byte{} xerrors := map[string][]byte{} // collect reads events and errors until the reply fencing them. collect := func(names map[byte]string) { t.Helper() write([]byte{43, 0, 1, 0}) // GetInputFocus for { p := packet() switch { case p[0] == 1: return case p[0] == 0: name := names[p[1]] if name == "" { t.Fatalf("unexpected error %x", p) } xerrors[name] = p default: name := names[p[0]] if name == "" { t.Fatalf("unexpected event %x", p) } events[name] = p } } } const ( maskSubstructureNotify = 1 << 19 maskSubstructureRedirect = 1 << 20 maskPropertyChange = 1 << 22 atomPrimary, atomSecondary, atomString = 1, 2, 31 ) selectRoot := make([]byte, 16) selectRoot[0], selectRoot[2] = 2, 4 binary.LittleEndian.PutUint32(selectRoot[4:], root) binary.LittleEndian.PutUint32(selectRoot[8:], 1<<11) // CWEventMask binary.LittleEndian.PutUint32(selectRoot[12:], maskSubstructureNotify|maskSubstructureRedirect|maskPropertyChange) write(selectRoot) selectSel := make([]byte, 16) selectSel[0], selectSel[1], selectSel[2] = major, 2, 4 binary.LittleEndian.PutUint32(selectSel[4:], root) binary.LittleEndian.PutUint32(selectSel[8:], atomPrimary) binary.LittleEndian.PutUint32(selectSel[12:], 1) // SetSelectionOwner write(selectSel) collect(nil) other, err := net.DialTimeout("unix", fmt.Sprintf("/tmp/.X11-unix/X%d", num), 5*time.Second) if err != nil { t.Fatal(err) } defer other.Close() _ = other.SetDeadline(time.Now().Add(5 * time.Second)) send := func(p []byte) { t.Helper() if _, err := other.Write(p); err != nil { t.Fatal(err) } } recv := func(p []byte) { t.Helper() if _, err := io.ReadFull(other, p); err != nil { t.Fatal(err) } } // sync round-trips the second client; any error fails the capture. sync := func() { t.Helper() send([]byte{43, 0, 1, 0}) var p [32]byte recv(p[:]) if p[0] != 1 { t.Fatalf("second client: %x", p) } } send([]byte{'l', 0, 11, 0, 0, 0, 0, 0, 0, 0, 0, 0}) var otherHead [8]byte recv(otherHead[:]) if otherHead[0] != 1 { t.Fatalf("second setup failed: %x", otherHead) } otherSetup := make([]byte, int(binary.LittleEndian.Uint16(otherHead[6:]))*4) recv(otherSetup) client2 := binary.LittleEndian.Uint32(otherSetup[4:]) | 1 win := make([]byte, 32) win[0], win[2] = 1, 8 binary.LittleEndian.PutUint32(win[4:], client2) binary.LittleEndian.PutUint32(win[8:], root) binary.LittleEndian.PutUint16(win[12:], 10) binary.LittleEndian.PutUint16(win[14:], 20) binary.LittleEndian.PutUint16(win[16:], 300) binary.LittleEndian.PutUint16(win[18:], 200) binary.LittleEndian.PutUint16(win[22:], 1) send(win) // CreateNotify mapOther := make([]byte, 8) mapOther[0], mapOther[2] = 8, 2 binary.LittleEndian.PutUint32(mapOther[4:], client2) send(mapOther) // MapRequest (redirected) cfg := make([]byte, 24) cfg[0], cfg[2] = 12, 6 binary.LittleEndian.PutUint32(cfg[4:], client2) binary.LittleEndian.PutUint16(cfg[8:], 1|2|4) // x, y, width binary.LittleEndian.PutUint32(cfg[12:], 0xfffffffb) binary.LittleEndian.PutUint32(cfg[16:], 40) binary.LittleEndian.PutUint32(cfg[20:], 640) send(cfg) // ConfigureRequest (redirected) prop := make([]byte, 28) prop[0], prop[2] = 18, 7 binary.LittleEndian.PutUint32(prop[4:], root) binary.LittleEndian.PutUint32(prop[8:], atom) binary.LittleEndian.PutUint32(prop[12:], atomString) prop[16] = 8 binary.LittleEndian.PutUint32(prop[20:], 2) copy(prop[24:], "hi") send(prop) // PropertyNotify (NewValue) msg := make([]byte, 44) msg[0], msg[2] = 25, 11 binary.LittleEndian.PutUint32(msg[4:], root) binary.LittleEndian.PutUint32(msg[8:], maskSubstructureNotify|maskSubstructureRedirect) msg[12], msg[13] = 33, 32 // ClientMessage, format 32 binary.LittleEndian.PutUint32(msg[16:], client2) binary.LittleEndian.PutUint32(msg[20:], atom) for i := range 5 { binary.LittleEndian.PutUint32(msg[24+4*i:], uint32(0x01020304*(i+1))) } send(msg) // ClientMessage (SendEvent bit set) owner := make([]byte, 16) owner[0], owner[2] = 22, 4 binary.LittleEndian.PutUint32(owner[4:], client2) binary.LittleEndian.PutUint32(owner[8:], atomPrimary) send(owner) // xfixes SelectionNotify (SetSelectionOwner) sync() collect(map[byte]string{16: "xproto/CreateNotify", 20: "xproto/MapRequest", 23: "xproto/ConfigureRequest", 28: "xproto/PropertyNotify", 0x80 | 33: "xproto/ClientMessage", xfixesEvent: "xfixes/SelectionNotify"}) // The window manager maps the window itself (MapNotify); the second client // then unmaps and destroys it. mapByWM := make([]byte, 8) mapByWM[0], mapByWM[2] = 8, 2 binary.LittleEndian.PutUint32(mapByWM[4:], client2) write(mapByWM) collect(map[byte]string{19: "xproto/MapNotify"}) unmap := []byte{10, 0, 2, 0, 0, 0, 0, 0} binary.LittleEndian.PutUint32(unmap[4:], client2) send(unmap) destroy := []byte{4, 0, 2, 0, 0, 0, 0, 0} binary.LittleEndian.PutUint32(destroy[4:], client2) send(destroy) sync() collect(map[byte]string{18: "xproto/UnmapNotify", 17: "xproto/DestroyNotify"}) // Core SelectionNotify: SECONDARY has no owner, so the server replies itself. convert := make([]byte, 24) convert[0], convert[2] = 24, 6 binary.LittleEndian.PutUint32(convert[4:], child) binary.LittleEndian.PutUint32(convert[8:], atomSecondary) binary.LittleEndian.PutUint32(convert[12:], atomString) binary.LittleEndian.PutUint32(convert[16:], atom) write(convert) collect(map[byte]string{31: "xproto/SelectionNotify"}) // Errors: BadAtom, BadWindow and an extension error (XFIXES BadRegion). badAtom := []byte{17, 0, 2, 0, 0xef, 0xbe, 0xad, 0x0d} write(badAtom) badWindow := []byte{3, 0, 2, 0, 0, 0, 0, 0} binary.LittleEndian.PutUint32(badWindow[4:], base|0x7777) write(badWindow) badRegion := []byte{major, 10, 2, 0, 0, 0, 0, 0} binary.LittleEndian.PutUint32(badRegion[4:], base|0x8888) write(badRegion) collect(map[byte]string{5: "xproto/BadAtom", 3: "xproto/BadWindow", xfixesError: "xfixes/BadRegion"}) if len(events) != 10 || len(xerrors) != 3 { t.Fatalf("captured %d events, %d errors", len(events), len(xerrors)) } // Closing the client drains the request direction; wait before inspecting tee. client.Close() select { case err := <-copied: if err != nil { t.Fatal(err) } case <-time.After(5 * time.Second): t.Fatal("tee did not drain") } stream := recorded.Bytes() requests := stream[12:] captured := map[string][]byte{} configIndex := 0 for len(requests) > 0 { size := int(binary.LittleEndian.Uint16(requests[2:])) * 4 if size < 4 || size > len(requests) { t.Fatal("malformed capture") } p := requests[:size] switch p[0] { case 1: if binary.LittleEndian.Uint32(p[4:]) == child { captured["xproto/CreateWindow"] = p } case 2: if binary.LittleEndian.Uint32(p[4:]) == child { captured["xproto/ChangeWindowAttributes"] = p } case 12: names := []string{"ConfigureWindow", "ConfigureWindowSparse", "ConfigureWindowAll"} captured["xproto/"+names[configIndex]] = p configIndex++ case 8: if binary.LittleEndian.Uint32(p[4:]) == root { captured["xproto/MapWindow"] = p } case 14: captured["xproto/GetGeometry"] = p case 16: captured["xproto/InternAtom"] = p case 18: captured["xproto/ChangeProperty"] = p default: if p[0] == renderMajor && p[1] == 4 { captured["render/CreatePicture"] = p } if p[0] == resMajor && p[1] == 4 { captured["res/QueryClientIds"] = p } if p[0] == major && p[1] == 0 { captured["xfixes/QueryVersion"] = p } } requests = requests[size:] } if len(captured) != 12 || len(replies) != 11 { t.Fatal("missing captured requests or replies") } out := map[string][]byte{} for name, p := range replies { out[name+".reply"] = p } for name, p := range captured { out[name+".req"] = p } for name, p := range events { out[name+".event"] = p } for name, p := range xerrors { out[name+".error"] = p } // Server-chosen values, keyed by the roles used in serverFields. roles := map[string]uint32{ "root": root, "atom": atom, "base": base, "child": child, "sibling": sibling, "picture": picture, "client2": client2, "badWindow": base | 0x7777, "badRegion": base | 0x8888, "rootVisual": rootVisual, "rootColormap": binary.LittleEndian.Uint32(setup[screenOff+4:]), "rootFormat": format, "xfixesMajor": uint32(major), "xfixesEvent": uint32(xfixesEvent), "xfixesError": uint32(xfixesError), "resMajor": uint32(resMajor), "renderMajor": uint32(renderMajor), } if *update { for name, p := range out { writeGolden(t, name, p) } rolesJSON, err := json.MarshalIndent(roles, "", " ") if err != nil { t.Fatal(err) } if err := os.WriteFile(rolesPath, append(rolesJSON, '\n'), 0644); err != nil { t.Fatal(err) } return } rolesJSON, err := os.ReadFile(rolesPath) if err != nil { t.Fatal(err) } var capturedRoles map[string]uint32 if err := json.Unmarshal(rolesJSON, &capturedRoles); err != nil { t.Fatal(err) } for _, role := range slices.Sorted(maps.Keys(roles)) { if roles[role] != capturedRoles[role] { t.Logf("server-chosen %s: live %#x, golden %#x", role, roles[role], capturedRoles[role]) } } for name, p := range out { compareGolden(t, name, p, roles, capturedRoles) } } var sources = map[string]string{ "req": "# source: Xvfb via raw socketpair tee; independent reference encoder; server accepted\n", "reply": "# source: Xvfb reply via raw socketpair tee to an independent reference request\n", "event": "# source: Xvfb event via raw socketpair tee; triggered by an independent second client or reference request\n", "error": "# source: Xvfb error via raw socketpair tee to an independent reference request\n", } func writeGolden(t *testing.T, name string, p []byte) { t.Helper() path := filepath.Join("../../testdata/golden", name+".hex") if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { t.Fatal(err) } source := sources[name[strings.LastIndexByte(name, '.')+1:]] text := source + "# xtrace fallback: local xtrace is glibc's execution tracer, not X11 xtrace\n" + hex.EncodeToString(p) + "\n" if err := os.WriteFile(path, []byte(text), 0644); err != nil { t.Fatal(err) } } // compareGolden checks a live packet against the committed golden without // depending on the server build: server-chosen fields must hold each side's // own value (live roles, recorded roles) and are masked before an exact // comparison; structural replies are checked for invariants only. func compareGolden(t *testing.T, name string, live []byte, roles, recorded map[string]uint32) { t.Helper() want, err := LoadHex(filepath.Join("../../testdata/golden", name+".hex")) if err != nil { t.Errorf("%s: %v (run with -update to create it)", name, err) return } if check := structural[name]; check != nil { if err := check(live, roles); err != nil { t.Errorf("%s (live): %v", name, err) } if err := check(want, recorded); err != nil { t.Errorf("%s (golden): %v", name, err) } return } got, exp, err := normalize(name, live, roles), normalize(name, want, recorded), error(nil) for _, e := range []struct { side string err error }{{"live", got.err}, {"golden", exp.err}} { if e.err != nil { t.Errorf("%s (%s): %v", name, e.side, e.err) err = e.err } } if err == nil && !bytes.Equal(got.b, exp.b) { t.Errorf("%s differs from live Xvfb after masking server-chosen fields:\n got %x\nwant %x", name, live, want) } } type normalized struct { b []byte err error } // normalize verifies each server-chosen field of p holds roles[Role] and // replaces it with zero bytes. func normalize(name string, p []byte, roles map[string]uint32) normalized { b := bytes.Clone(p) for _, f := range serverFields[name] { if f.Off+f.Size > len(b) { return normalized{err: fmt.Errorf("field %s at %d beyond %d bytes", f.Role, f.Off, len(b))} } if f.Role != "time" { v, ok := roles[f.Role] if !ok { return normalized{err: fmt.Errorf("no recorded value for role %s", f.Role)} } var got uint32 if f.Size == 1 { got = uint32(b[f.Off]) } else { got = binary.LittleEndian.Uint32(b[f.Off:]) } if got != v { return normalized{err: fmt.Errorf("field %s at %d = %#x, want %#x", f.Role, f.Off, got, v)} } } clear(b[f.Off : f.Off+f.Size]) } return normalized{b: b} } // checkListExtensions: a framed reply whose STR list holds exactly names_len // entries filling the reply, including the extensions xturbo binds. func checkListExtensions(p []byte, _ map[string]uint32) error { if len(p) < 32 || p[0] != 1 || len(p) != 32+4*int(binary.LittleEndian.Uint32(p[4:])) { return fmt.Errorf("bad framing") } names := map[string]bool{} off := 32 for range int(p[1]) { if off >= len(p) || off+1+int(p[off]) > len(p) { return fmt.Errorf("STR list overruns the reply") } names[string(p[off+1:off+1+int(p[off])])] = true off += 1 + int(p[off]) } if (off+3)&^3 != len(p) { return fmt.Errorf("STR list ends at %d, reply has %d bytes", off, len(p)) } for _, want := range []string{"BIG-REQUESTS", "XC-MISC", "XFIXES", "RENDER", "SHAPE", "Composite", "X-Resource", "Generic Event Extension"} { if !names[want] { return fmt.Errorf("missing extension %q", want) } } return nil } // checkQueryPictFormats: declared counts match the list sizes exactly and the // root visual maps to the picture format CreatePicture used. func checkQueryPictFormats(p []byte, roles map[string]uint32) error { u32 := binary.LittleEndian.Uint32 if len(p) < 32 || p[0] != 1 || len(p) != 32+4*int(u32(p[4:])) { return fmt.Errorf("bad framing") } formats, screens, subpixels := int(u32(p[8:])), int(u32(p[12:])), int(u32(p[24:])) off := 32 + 28*formats found := false for range screens { if off+8 > len(p) { return fmt.Errorf("screens overrun") } depths := int(u32(p[off:])) off += 8 for range depths { if off+8 > len(p) { return fmt.Errorf("depths overrun") } visuals := int(binary.LittleEndian.Uint16(p[off+2:])) off += 8 for range visuals { if off+8 > len(p) { return fmt.Errorf("visuals overrun") } if u32(p[off:]) == roles["rootVisual"] && u32(p[off+4:]) == roles["rootFormat"] { found = true } off += 8 } } } off += 4 * subpixels if off != len(p) { return fmt.Errorf("lists end at %d, reply has %d bytes", off, len(p)) } if !found { return fmt.Errorf("root visual %#x does not map to format %#x", roles["rootVisual"], roles["rootFormat"]) } return nil }