Files
gasm-sdk/debug/debug_audit_test.go
T

326 lines
10 KiB
Go
Raw Normal View History

// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (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)
}