// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) // SPDX-License-Identifier: BSD-3-Clause //go:build linux && amd64 package debug import ( "fmt" "os" "runtime" "strings" "testing" "time" ) // Regression tests for the debugger audit: memory access at mapping // boundaries, watchpoint slot attribution, launch failure latency, stray // trap instructions and the REPL's argument validation. All drive a real // ptrace session, so they run on amd64 hosts only. // memMap is one line of /proc/pid/maps. type memMap struct { lo, hi uint64 perms string name string } // readMaps parses the debuggee's memory map. func readMaps(t *testing.T, pid int) []memMap { t.Helper() data, err := os.ReadFile(fmt.Sprintf("/proc/%d/maps", pid)) if err != nil { t.Fatalf("read maps: %v", err) } var out []memMap for line := range strings.SplitSeq(string(data), "\n") { fields := strings.Fields(line) if len(fields) < 2 { continue } var lo, hi uint64 if _, err := fmt.Sscanf(fields[0], "%x-%x", &lo, &hi); err != nil { continue } m := memMap{lo: lo, hi: hi, perms: fields[1]} if len(fields) >= 6 { m.name = fields[5] } out = append(out, m) } return out } // boundaryByte returns the last byte of a writable, ordinary mapping that is // followed by an unmapped gap: an access there is inside the mapping, while // the 8-byte word starting at it crosses into unmapped memory. func boundaryByte(t *testing.T, pid int) uint64 { t.Helper() maps := readMaps(t, pid) for i, m := range maps { if !strings.Contains(m.perms, "rw") || strings.Contains(m.name, "vvar") || strings.Contains(m.name, "vdso") || strings.Contains(m.name, "vsyscall") { continue } gap := uint64(1) << 62 if i+1 < len(maps) { gap = maps[i+1].lo - m.hi } if gap >= 4096 { return m.hi - 1 } } t.Skip("no writable mapping followed by a hole; cannot construct the boundary") return 0 } // TestReadMemoryPageBoundary proves ReadMemory never reads past the requested // range: one byte at the end of a mapping followed by a hole must be // readable, which the old word-at-a-time tail read failed because its final // 8-byte Peek crossed into the unmapped page. func TestReadMemoryPageBoundary(t *testing.T) { sess, _, _ := launchKernel(t, buildGasm(t), boundaryKernel(t), "boundary", nil) addr := boundaryByte(t, sess.Pid()) mem, err := sess.ReadMemory(addr, 1) if err != nil { t.Fatalf("ReadMemory(%#x, 1): %v (the read must not cross into the unmapped page)", addr, err) } if len(mem) != 1 { t.Fatalf("ReadMemory returned %d bytes, want 1", len(mem)) } // A request whose own range crosses into the hole must still fail. if _, err := sess.ReadMemory(addr, 8); err == nil { t.Fatal("ReadMemory past the mapping end should fail") } } // TestDisassemblePageBoundary proves the disassembler shrinks its read // window at a mapping end instead of failing: the instruction stream cannot // be decoded at all when the fixed 15-byte read crosses into the hole. func TestDisassemblePageBoundary(t *testing.T) { sess, _, _ := launchKernel(t, buildGasm(t), boundaryKernel(t), "boundary", nil) addr := boundaryByte(t, sess.Pid()) if _, _, err := sess.Disassemble(addr); err != nil { t.Fatalf("Disassemble(%#x): %v (the read window must shrink at the mapping end)", addr, err) } } // TestWriteMemoryPageBoundary proves WriteMemory writes exactly the bytes it // is given: one byte at the end of a mapping followed by a hole must be // writable, which the old read-modify-write of the final partial word failed // because its Peek crossed into the unmapped page. func TestWriteMemoryPageBoundary(t *testing.T) { sess, _, _ := launchKernel(t, buildGasm(t), boundaryKernel(t), "boundary", nil) addr := boundaryByte(t, sess.Pid()) orig, err := sess.ReadMemory(addr, 1) if err != nil { t.Fatalf("ReadMemory(%#x, 1): %v", addr, err) } if err := sess.WriteMemory(addr, []byte{orig[0]}); err != nil { t.Fatalf("WriteMemory(%#x, 1): %v (the write must not read past the range)", addr, err) } } // TestWatchpointSlotAttribution proves a hit is attributed to the slot that // fired, not to an earlier one whose DR6 status bit is still set: the B0-B3 // bits are sticky, so they must be acknowledged when read. func TestWatchpointSlotAttribution(t *testing.T) { bin := buildGasm(t) const kernel = `#include "textflag.h" // func wptwo(x, y int64) (a, b int64) TEXT ·wptwo(SB), NOSPLIT, $0-32 MOVQ $0x1111, AX MOVQ AX, a+16(FP) MOVQ $0x2222, BX MOVQ BX, b+24(FP) RET ` path := writeKernel(t, kernel) sess, bm, fl := launchKernel(t, bin, path, "wptwo", nil) entry := sess.CodeBase() + uint64(fl.Offset) if _, err := bm.Set(entry, "entry"); err != nil { t.Fatalf("Set: %v", err) } runToEntry(t, sess, bm, entry) regs, err := sess.GetRegs() if err != nil { t.Fatalf("GetRegs: %v", err) } // FP sits one word above the entry stack pointer (the return address // occupies [RSP]), so a+16(FP) = RSP+24 and b+24(FP) = RSP+32. watchA := regs.RSP + 24 watchB := regs.RSP + 32 if err := sess.SetWatchpoint(0, watchA, WatchWrite, 8); err != nil { t.Fatalf("SetWatchpoint(0): %v", err) } if err := sess.SetWatchpoint(1, watchB, WatchWrite, 8); err != nil { t.Fatalf("SetWatchpoint(1): %v", err) } for i, want := range []uint64{watchA, watchB} { if err := sess.Continue(); err != nil { t.Fatalf("Continue (hit %d): %v", i+1, err) } reason, addr := sess.StopInfo() if reason != StopWatchpoint { t.Fatalf("hit %d: stop reason = %v, want StopWatchpoint", i+1, reason) } if addr != want { t.Fatalf("hit %d reported %#x, want %#x (the sticky DR6 bit misattributes the slot)", i+1, addr, want) } } // Clearing a watchpoint must zero its address register: a stale // address in a disabled slot turns any sticky status bit into a // misattributed report later. if err := sess.ClearWatchpoint(0); err != nil { t.Fatalf("ClearWatchpoint(0): %v", err) } dr0, err := ptracePeekUser(sess.Pid(), drOffset) if err != nil { t.Fatalf("read DR0: %v", err) } if dr0 != 0 { t.Fatalf("DR0 = %#x after ClearWatchpoint, want 0 (the address register must be cleared)", dr0) } } // TestLaunchFailsFastOnDeadDebuggee proves a debuggee that dies before // signalling readiness surfaces promptly: the ready poll used to run its // full 2.5 seconds before the wait discovered the exit. func TestLaunchFailsFastOnDeadDebuggee(t *testing.T) { runtime.LockOSThread() defer runtime.UnlockOSThread() bin := buildGasm(t) path := boundaryKernel(t) start := time.Now() sess, err := Launch(bin, path, "nosuchfunction", nil) elapsed := time.Since(start) if err == nil { sess.Kill() t.Fatal("Launch with an unknown function should fail") } if !strings.Contains(err.Error(), "before signalling readiness") && !strings.Contains(err.Error(), "debuggee exited") { t.Errorf("error does not name the dead debuggee: %v", err) } if elapsed >= 1500*time.Millisecond { t.Fatalf("Launch took %v to report the dead debuggee; the readiness poll must detect the exit, not time out", elapsed) } } // TestStrayTrapRunsThrough proves the continue loop survives a trap // instruction planted in the kernel itself (BYTE $0xCC, the same byte the // debugger patches in): on architectures that report the trap in place the // loop must surface the stop, and on amd64 it runs through to the exit. A // regression here hangs, so a watchdog fails the run. func TestStrayTrapRunsThrough(t *testing.T) { bin := buildGasm(t) const kernel = `#include "textflag.h" // func stray() int64 TEXT ·stray(SB), NOSPLIT, $0-8 MOVQ $7, AX BYTE $0xCC MOVQ AX, ret+0(FP) RET ` path := writeKernel(t, kernel) sess, bm, _ := launchKernel(t, bin, path, "stray", nil) timer := time.AfterFunc(time.Minute, func() { panic("watchdog: the continue loop hung on the stray trap instruction") }) defer timer.Stop() out := captureStdout(t, func() { REPL(sess, bm, sess.CodeBase(), 0, 0, 0, nil, nil, strings.NewReader("continue\nquit\n")) }) if !strings.Contains(out, "debuggee exited") { t.Errorf("the stray trap wedged the continue loop; output:\n%s", out) } } // TestStepIntoFaultReportsSignal proves the step command reports a genuine // signal-delivery-stop instead of silently printing the faulting // instruction as if the step had succeeded. func TestStepIntoFaultReportsSignal(t *testing.T) { bin := buildGasm(t) const kernel = `#include "textflag.h" // func crash() int64 TEXT ·crash(SB), NOSPLIT, $0-8 XORQ AX, AX MOVQ (AX), AX MOVQ AX, ret+0(FP) RET ` path := writeKernel(t, kernel) sess, bm, fl := launchKernel(t, bin, path, "crash", nil) entry := sess.CodeBase() + uint64(fl.Offset) if _, err := bm.Set(entry, "entry"); err != nil { t.Fatalf("Set: %v", err) } runToEntry(t, sess, bm, entry) out := captureStdout(t, func() { REPL(sess, bm, sess.CodeBase(), fl.Offset, fl.Size, fl.Args, nil, nil, strings.NewReader("step 2\nquit\n")) }) if !strings.Contains(out, "stopped on signal") { t.Errorf("stepping into the fault did not report the signal; output:\n%s", out) } } // TestREPLRejectsBadArguments proves the command loop reports malformed // input instead of silently defaulting: an unknown label for x would read // address 0, and a malformed count would silently step one instruction. func TestREPLRejectsBadArguments(t *testing.T) { bin := buildGasm(t) path := boundaryKernel(t) sess, bm, fl := launchKernel(t, bin, path, "boundary", nil) entry := sess.CodeBase() + uint64(fl.Offset) if _, err := bm.Set(entry, "entry"); err != nil { t.Fatalf("Set: %v", err) } runToEntry(t, sess, bm, entry) out := captureStdout(t, func() { REPL(sess, bm, sess.CodeBase(), fl.Offset, fl.Size, fl.Args, nil, nil, strings.NewReader("x nosuchlabel\nstep abc\ndisas abc\nwatch 0x1000 q 8\nquit\n")) }) for _, want := range []string{ "unknown address: nosuchlabel", "invalid count: abc", "unknown watchpoint type: q", } { if !strings.Contains(out, want) { t.Errorf("output missing %q:\n%s", want, out) } } if got := strings.Count(out, "invalid count: abc"); got != 2 { t.Errorf("invalid count reported %d times, want 2 (step and disas):\n%s", got, out) } } // boundaryKernel is a minimal kernel for the boundary tests, which only need // a live, stopped debuggee. func boundaryKernel(t *testing.T) string { t.Helper() const kernel = `#include "textflag.h" // func boundary() int64 TEXT ·boundary(SB), NOSPLIT, $0-8 MOVQ $1, AX MOVQ AX, ret+0(FP) RET ` return writeKernel(t, kernel) }