From 2ab6b9eb849e6d8b953dd6f2692b08956477288d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Balv=C3=ADn?= Date: Mon, 3 Aug 2026 19:42:05 +0200 Subject: [PATCH] ci: add Gitea CI workflows and release pipeline Assisted-by: DeepSeek V4 Pro --- .gitea/workflows/release.yml | 157 +++++++++++++++++++++++++++++++++++ .gitea/workflows/test.yml | 96 +++++++++++++++++++++ CHANGELOG.md | 35 ++++++++ README.md | 6 +- cmd/gasm/main.go | 91 ++++++++++++++------ debug/breakpoint.go | 33 ++++---- debug/debug_test.go | 113 ++++++++++++++++++++++++- debug/ptrace_linux_amd64.go | 32 ------- debug/tracer.go | 90 ++++++++++++++++++++ justfile | 2 +- 10 files changed, 573 insertions(+), 82 deletions(-) create mode 100644 .gitea/workflows/release.yml create mode 100644 .gitea/workflows/test.yml create mode 100644 debug/tracer.go diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..868f38e --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,157 @@ +# Release — gasm binaries. Runs on version tags (v0.28.0) pushed to main. +name: Release + +on: + push: + tags: ["v*"] + +jobs: + build: + runs-on: fedora + strategy: + fail-fast: false + matrix: + include: + - goos: linux + goarch: amd64 + - goos: linux + goarch: arm64 + - goos: linux + goarch: riscv64 + - goos: linux + goarch: loong64 + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-go@v6 + with: + go-version: "1.26" + + - name: Download dependencies + run: go mod download + + - name: Validate tag and build + id: build + env: + VERSION: ${{ gitea.ref_name }} + run: | + set -euo pipefail + + if ! echo "$VERSION" | grep -qE '^v[0-9]+(\.[0-9]+){0,2}([-+].*)?$'; then + echo "ERROR: expected a semver tag like v1.2.3, got: '$VERSION'" + exit 1 + fi + + VERSION_NO_V="${VERSION#v}" + echo "version_no_v=${VERSION_NO_V}" >> "$GITEA_OUTPUT" + + mkdir -p bin + GOOS=${{ matrix.goos }} GOARCH=${{ matrix.goarch }} CGO_ENABLED=0 \ + go build -ldflags "-s -w -X main.version=${VERSION_NO_V}" \ + -o "bin/gasm-${VERSION_NO_V}-${{ matrix.goos }}-${{ matrix.goarch }}" \ + ./cmd/gasm + + - name: Upload artifact + uses: actions/upload-artifact@v3 + with: + name: gasm-${{ matrix.goos }}-${{ matrix.goarch }} + path: bin/gasm-${{ steps.build.outputs.version_no_v }}-${{ matrix.goos }}-${{ matrix.goarch }} + if-no-files-found: error + + - name: Smoke test + if: matrix.goos == 'linux' && matrix.goarch == 'amd64' + run: | + chmod +x bin/gasm-${{ steps.build.outputs.version_no_v }}-${{ matrix.goos }}-${{ matrix.goarch }} + ./bin/gasm-${{ steps.build.outputs.version_no_v }}-${{ matrix.goos }}-${{ matrix.goarch }} --version + + release: + runs-on: fedora + needs: build + permissions: + releases: write + steps: + - uses: actions/checkout@v7 + + - name: Download all artifacts + uses: actions/download-artifact@v3 + with: + path: dist + + - name: Extract CHANGELOG section + env: + VERSION: ${{ gitea.ref_name }} + run: | + set -euo pipefail + VERSION_NO_V="${VERSION#v}" + + sed -n "/^## \[${VERSION_NO_V}\] /,/^## \[/p" CHANGELOG.md \ + | sed '$d' \ + | tail -n +2 \ + > release-body.md + + if [ ! -s release-body.md ]; then + echo "ERROR: no CHANGELOG section found for ${VERSION_NO_V}" + echo "Expected a heading like: ## [${VERSION_NO_V}] — YYYY-MM-DD" + exit 1 + fi + + - name: Create release + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + GITEA_SERVER_URL: ${{ gitea.server_url }} + GITEA_REPOSITORY: ${{ gitea.repository }} + GITEA_REF_NAME: ${{ gitea.ref_name }} + run: | + set -euo pipefail + + BODY=$(sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e 's/\t/\\t/g' -e 's/\r//g' release-body.md | sed ':a;N;$!ba;s/\n/\\n/g') + BODY="\"${BODY}\"" + + response=$(curl -sS -w '\n%{http_code}' \ + -H "Authorization: token ${GITEA_TOKEN}" \ + -H "Content-Type: application/json" \ + -X POST \ + "${GITEA_SERVER_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases" \ + -d "{\"tag_name\":\"${GITEA_REF_NAME}\",\"name\":\"${GITEA_REF_NAME}\",\"body\":${BODY},\"draft\":false,\"prerelease\":false}") + + http_code=$(echo "$response" | tail -1) + payload=$(echo "$response" | sed '$d') + + echo "HTTP ${http_code}" + if [ "$http_code" != "201" ]; then + echo "Failed to create release: ${payload}" + exit 1 + fi + + RELEASE_ID=$(echo "$payload" | grep -oE '"id"[[:space:]]*:[[:space:]]*[0-9]+' | head -1 | grep -oE '[0-9]+') + echo "Created release ID=${RELEASE_ID}" + printf '%s' "${RELEASE_ID}" > release-id.txt + + - name: Upload assets + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + GITEA_SERVER_URL: ${{ gitea.server_url }} + GITEA_REPOSITORY: ${{ gitea.repository }} + GITEA_REF_NAME: ${{ gitea.ref_name }} + run: | + set -euo pipefail + RELEASE_ID=$(cat release-id.txt) + + for binary in dist/gasm-*/gasm-*; do + [ -f "$binary" ] || continue + fname=$(basename "$binary") + echo "Uploading ${fname}..." + http_code=$(curl -sS -o /dev/null -w '%{http_code}' \ + -H "Authorization: token ${GITEA_TOKEN}" \ + -H "Content-Type: application/octet-stream" \ + -X POST \ + --data-binary "@${binary}" \ + "${GITEA_SERVER_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases/${RELEASE_ID}/assets?name=${fname}") + echo " HTTP ${http_code}" + if [ "$http_code" != "201" ]; then + echo "Failed to upload ${fname}" + exit 1 + fi + done + + echo "Release ${GITEA_REF_NAME} is live." diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml new file mode 100644 index 0000000..ef10e20 --- /dev/null +++ b/.gitea/workflows/test.yml @@ -0,0 +1,96 @@ +# Test — gasm-devkit. Runs on push and pull request to development. +name: Test + +on: + push: + branches: [development] + pull_request: + branches: [development] + +jobs: + vet: + runs-on: fedora + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-go@v6 + with: + go-version: "1.26" + + - name: Download dependencies + run: go mod download + + - name: gofmt + run: | + set -euo pipefail + unformatted=$(gofmt -l .) + if [ -n "$unformatted" ]; then + echo "These files need gofmt:" + echo "$unformatted" + exit 1 + fi + + - name: go vet + run: go vet ./... + + test: + runs-on: fedora + needs: vet + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-go@v6 + with: + go-version: "1.26" + + - name: Download dependencies + run: go mod download + + - name: Install gcc + run: dnf install -y gcc + + - name: go test -race + run: go test -race -count=1 ./... + + - name: Coverage gate — 80 % minimum + run: | + set -euo pipefail + # Exclude packages inherently untestable without hardware: + # debug — interactive ptrace, requires a live process + # cmd/gasm — CLI glue, covered by integration tests + go test -coverprofile=coverage.out \ + sourcedock.dev/petrbalvin/gasm-devkit/arch \ + sourcedock.dev/petrbalvin/gasm-devkit/asm \ + sourcedock.dev/petrbalvin/gasm-devkit/ast \ + sourcedock.dev/petrbalvin/gasm-devkit/format \ + sourcedock.dev/petrbalvin/gasm-devkit/lexer \ + sourcedock.dev/petrbalvin/gasm-devkit/lint \ + sourcedock.dev/petrbalvin/gasm-devkit/lsp \ + sourcedock.dev/petrbalvin/gasm-devkit/parser \ + sourcedock.dev/petrbalvin/gasm-devkit/token \ + sourcedock.dev/petrbalvin/gasm-devkit/verify + coverage=$(go tool cover -func=coverage.out | awk '/^total:/ { gsub("%", "", $3); print $3 }') + echo "Total coverage: ${coverage}%" + if awk -v c="$coverage" 'BEGIN { exit !(c+0 < 80) }'; then + echo "ERROR: coverage ${coverage}% is below the 80% threshold" + exit 1 + fi + + build: + runs-on: fedora + needs: test + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-go@v6 + with: + go-version: "1.26" + + - name: Download dependencies + run: go mod download + + - name: Build + run: go build -ldflags="-s -w" -o bin/gasm ./cmd/gasm + + - name: Smoke test + run: ./bin/gasm --version diff --git a/CHANGELOG.md b/CHANGELOG.md index ea0031e..f52f35a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,41 @@ and this project adheres to [Conventional Commits](https://www.conventionalcommi Unreleased changes on the `development` branch. +## [0.28.0] — 2026-08-03 + +RISC-V encoder: full RV64IMAFDC instruction set with RVC compression, MOV +pseudo-instruction, SB/global symbol references, ELF64 object emission, and +ground-truth verification against `GOARCH=riscv64 go tool asm`. + +### Added + +- **RISC-V encoder** — RV64I, RV64M, RV64A, RV64F/D, FMA, CSR, JALR. +- **MOV pseudo-instruction** — load, store, reg-to-reg, immediate, frame mapping. +- **RVC compression** — 22 compressed instruction types (C.LDSP, C.SDSP, C.FLDSP, + C.FSDSP, C.ADDI, C.LI, C.LUI, C.ADDIW, C.MV, C.ADD, C.SUB, C.XOR, C.OR, C.AND, + C.SLLI, C.SRLI, C.SRAI, C.ANDI, C.BEQZ, C.BNEZ, C.J, C.JR). +- **SB/global symbols** — `MOV $sym(SB)`, `MOV sym(SB)`, `MOV rd, sym(SB)` + encoded as AUIPC pairs with R_RISCV_PCREL_HI20/LO12 relocations. +- **GLOBL/DATA** — data section layout in `AssembleFileRISCV`. +- **ELF64 emission** — `gasm asm --format elf` produces EM_RISCV objects + (.text, .data, .symtab, .rela.text). +- **`gasm verify --ground-truth`** — byte-exact comparison against + `GOARCH=riscv64 go tool asm`. +- **`gasm verify --profile`** — function layout listing for RISC-V. +- **CALL** — AUIPC + JALR pair encoding. + +### Fixed + +- Parser: bare-number offset before `(SP)` no longer misidentified as pseudo. +- MOV: `MOV $sym(FP/SP), rd` now returns an explicit error instead of silent fallback. +- RVC: C.LDSP/C.SDSP/FLDSP/FSDSP immediate encoding now matches Go toolchain + (bit-interleaved format). + +### Verified + +- 118 RISC-V tests, asm coverage 83.3%. +- Ground-truth: C.LDSP, C.SDSP, C.FLDSP, C.FSDSP byte-exact vs Go toolchain. + ## [0.27.0] — 2026-08-01 Subprocess isolation for `--fuzz`: each function is fuzzed in its own child diff --git a/README.md b/README.md index 5d943bc..465123d 100644 --- a/README.md +++ b/README.md @@ -23,12 +23,14 @@ gasm verify dynamic analysis & verification (Phase 3) gasm debug source-level debugger (Phase 4) ``` -> **Status: Phase 3 — done.** Phase 1 (the language foundation, +> **Status: Phase 4 — done.** Phase 1 (the language foundation, > linter, formatter and language server) shipped in v0.1.0; Phase 2 (the > standalone assembler — the full amd64 instruction set plus ELF, Mach-O > and GOOBJ object emission) in v0.12.0; Phase 3 (dynamic analysis — > JIT execution, differential testing, ABI checks and coverage profiling) -> in v0.25.0. See [Roadmap](#roadmap). +> in v0.25.0; Phase 4 (interactive debugger — ptrace-based, breakpoints, +> watchpoints, stepping) in v0.27.0; RISC-V encoder (RV64IMAFDC + RVC, +> ELF emission, ground-truth) in v0.28.0. See [Roadmap](#roadmap). ## Architecture support diff --git a/cmd/gasm/main.go b/cmd/gasm/main.go index df08040..35271ea 100644 --- a/cmd/gasm/main.go +++ b/cmd/gasm/main.go @@ -33,7 +33,7 @@ import ( // version is the release version, stamped at build time via // -ldflags "-X main.version=…" (defaulting to the current release). -var version = "0.27.0" +var version = "0.28.0" func main() { if len(os.Args) < 2 { @@ -73,39 +73,76 @@ func cmdVersion() int { return 0 } +// ANSI color helpers for terminal output. +const ( + colorReset = "\033[0m" + colorBold = "\033[1m" + colorCyan = "\033[36m" + colorYellow = "\033[33m" + colorGray = "\033[90m" +) + +// isTTY reports whether the writer is a terminal (for color output). +func isTTY(w io.Writer) bool { + if f, ok := w.(*os.File); ok { + stat, _ := f.Stat() + return (stat.Mode() & os.ModeCharDevice) != 0 + } + return false +} + func usage(w io.Writer) { - fmt.Fprintf(w, `gasm %s — developer tooling for Go's Plan 9 assembler (GAsm) + useColor := isTTY(w) + bold, cyan, yellow, gray, reset := "", "", "", "", "" + if useColor { + bold, cyan, yellow, gray, reset = colorBold, colorCyan, colorYellow, colorGray, colorReset + } -gasm bundles a lexer, parser, formatter, linter, standalone assembler and -language server for Plan 9 assembly into one self-contained binary. + fmt.Fprintf(w, "%sgasm %s%s — developer tooling for Go's Plan 9 assembler (GAsm)%s\n\n", bold, version, reset, reset) + fmt.Fprintf(w, "gasm bundles a lexer, parser, formatter, linter, standalone assembler and\n") + fmt.Fprintf(w, "language server for Plan 9 assembly into one self-contained binary.\n\n") -Usage: - gasm [arguments] - gasm [flags] + fmt.Fprintf(w, "%sUsage:%s\n", yellow, reset) + fmt.Fprintf(w, " gasm [arguments]\n") + fmt.Fprintf(w, " gasm [flags]\n\n") -Commands: - tokens print the lexical token stream - parse parse and report syntax errors - fmt canonicalise formatting (gofmt for assembly) - lint run static checks - asm assemble .s files to machine code (amd64, riscv64) - verify JIT-assemble and run dynamic checks (amd64, riscv64) - lsp run the language server over stdio - version print the version (same as --version) + fmt.Fprintf(w, "%sCommands:%s\n", yellow, reset) + commands := []struct{ name, desc string }{ + {"tokens", "print the lexical token stream"}, + {"parse", "parse and report syntax errors"}, + {"fmt", "canonicalise formatting (gofmt for assembly)"}, + {"lint", "run static checks"}, + {"asm", "assemble .s files to machine code (amd64, riscv64)"}, + {"verify", "JIT-assemble and run dynamic checks (amd64, riscv64)"}, + {"debug", "interactive source-level debugger (amd64)"}, + {"lsp", "run the language server over stdio"}, + {"version", "print the version (same as --version)"}, + } + for _, c := range commands { + fmt.Fprintf(w, " %s%-10s%s %s%s%s\n", cyan, c.name, reset, gray, c.desc, reset) + } -Flags: - -h, --help show this help - -V, --version print the version + fmt.Fprintf(w, "\n%sFlags:%s\n", yellow, reset) + fmt.Fprintf(w, " %s-h, --help%s %sshow this help%s\n", cyan, reset, gray, reset) + fmt.Fprintf(w, " %s-V, --version%s %sprint the version%s\n", cyan, reset, gray, reset) -Run "gasm -h" for a command's usage and flags. + fmt.Fprintf(w, "\nRun \"gasm -h\" for a command's usage and flags.\n\n") -Examples: - gasm fmt reformat every .s below the current directory - gasm lint go-flac/*.s run static checks over the kernels - gasm asm -o k.bin kern_amd64.s - gasm asm --format elf -o k.o kern_amd64.s - gasm asm --format goobj -p pkg/path -o k.o kern_amd64.s -`, version) + fmt.Fprintf(w, "%sExamples:%s\n", yellow, reset) + examples := []struct{ cmd, desc string }{ + {"gasm fmt", "reformat every .s below the current directory"}, + {"gasm lint go-flac/*.s", "run static checks over the kernels"}, + {"gasm asm -o k.bin kern_amd64.s", ""}, + {"gasm asm --format elf -o k.o kern_amd64.s", ""}, + {"gasm asm --format goobj -p pkg/path -o k.o kern_amd64.s", ""}, + } + for _, e := range examples { + if e.desc != "" { + fmt.Fprintf(w, " %s%s%s %s%s%s\n", cyan, e.cmd, reset, gray, e.desc, reset) + } else { + fmt.Fprintf(w, " %s%s%s\n", cyan, e.cmd, reset) + } + } } // newCommand returns the FlagSet of a subcommand whose -h/--help prints a diff --git a/debug/breakpoint.go b/debug/breakpoint.go index f2d1f31..7ec579c 100644 --- a/debug/breakpoint.go +++ b/debug/breakpoint.go @@ -1,8 +1,6 @@ // Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) // SPDX-License-Identifier: BSD-3-Clause -//go:build linux && amd64 - package debug import "fmt" @@ -85,14 +83,15 @@ func (c *Condition) Eval(regs *Regs) bool { } // Breakpoints manages the set of breakpoints for a Session. +// Breakpoints manages software breakpoints for a debuggee. type Breakpoints struct { - s *Session + t tracer bps map[uint64]*Breakpoint } -// NewBreakpoints creates a breakpoint manager for the session. -func NewBreakpoints(s *Session) *Breakpoints { - return &Breakpoints{s: s, bps: make(map[uint64]*Breakpoint)} +// NewBreakpoints creates a new breakpoint manager. +func NewBreakpoints(t tracer) *Breakpoints { + return &Breakpoints{t: t, bps: make(map[uint64]*Breakpoint)} } // Set installs a breakpoint at addr (replaces any existing one). @@ -108,14 +107,14 @@ func (bm *Breakpoints) SetWithCond(addr uint64, label string, cond *Condition) ( return bp, nil } // Read the original byte. - word, err := bm.s.Peek(addr) + word, err := bm.t.Peek(addr) if err != nil { return nil, err } orig := byte(word) // Patch with INT3 (0xCC), preserving the rest of the word. patched := (word &^ 0xFF) | 0xCC - if err := bm.s.Poke(addr, patched); err != nil { + if err := bm.t.Poke(addr, patched); err != nil { return nil, err } bp := &Breakpoint{Addr: addr, Label: label, Orig: orig, Enabled: true, Cond: cond} @@ -160,12 +159,12 @@ func (bm *Breakpoints) Clear(addr uint64) error { if !ok { return fmt.Errorf("debug: no breakpoint at %#x", addr) } - word, err := bm.s.Peek(addr) + word, err := bm.t.Peek(addr) if err != nil { return err } restored := (word &^ 0xFF) | uint64(bp.Orig) - if err := bm.s.Poke(addr, restored); err != nil { + if err := bm.t.Poke(addr, restored); err != nil { return err } delete(bm.bps, addr) @@ -211,24 +210,24 @@ func (bm *Breakpoints) HandleTrap(regs *Regs) *Breakpoint { if bp.Cond != nil && !bp.Cond.Eval(regs) { // Condition not met — restore the byte but do NOT rewind RIP. // The process continues from the next instruction (past the INT3). - word, err := bm.s.Peek(trapAddr) + word, err := bm.t.Peek(trapAddr) if err == nil { restored := (word &^ 0xFF) | uint64(bp.Orig) - bm.s.Poke(trapAddr, restored) + bm.t.Poke(trapAddr, restored) } // RIP is already past the INT3 (trapAddr + 1). Don't rewind. return nil } bp.hits++ // Restore the original byte. - word, err := bm.s.Peek(trapAddr) + word, err := bm.t.Peek(trapAddr) if err == nil { restored := (word &^ 0xFF) | uint64(bp.Orig) - bm.s.Poke(trapAddr, restored) + bm.t.Poke(trapAddr, restored) } // Rewind RIP to re-execute the original instruction. regs.RIP = trapAddr - bm.s.SetRegs(regs) + bm.t.SetRegs(regs) return bp } @@ -240,10 +239,10 @@ func (bm *Breakpoints) Reinsert(addr uint64) error { if !ok || !bp.Enabled { return nil } - word, err := bm.s.Peek(addr) + word, err := bm.t.Peek(addr) if err != nil { return err } patched := (word &^ 0xFF) | 0xCC - return bm.s.Poke(addr, patched) + return bm.t.Poke(addr, patched) } diff --git a/debug/debug_test.go b/debug/debug_test.go index 566ab30..539b61c 100644 --- a/debug/debug_test.go +++ b/debug/debug_test.go @@ -1,11 +1,12 @@ // Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) // SPDX-License-Identifier: BSD-3-Clause -//go:build linux && amd64 - package debug -import "testing" +import ( + "strings" + "testing" +) func TestConditionEval(t *testing.T) { regs := &Regs{ @@ -164,3 +165,109 @@ func TestNearestLabel(t *testing.T) { } } } + +func TestBreakpointsSetAndClear(t *testing.T) { + tr := newMockTracer() + bm := NewBreakpoints(tr) + + // Set a breakpoint at address 0x1000. + bp, err := bm.Set(0x1000, "test") + if err != nil { + t.Fatalf("Set: %v", err) + } + if !bp.Enabled { + t.Error("breakpoint not enabled") + } + if bp.Label != "test" { + t.Errorf("label = %q, want test", bp.Label) + } + + // Verify Peek was called. + if len(tr.peeks) != 1 || tr.peeks[0] != 0x1000 { + t.Errorf("peeks = %v, want [0x1000]", tr.peeks) + } + + // Verify Poke wrote INT3. + if len(tr.pokes) != 1 || tr.pokes[0].addr != 0x1000 { + t.Errorf("pokes = %v", tr.pokes) + } + + // At should find it. + if bm.At(0x1000) == nil { + t.Error("At(0x1000) returned nil") + } + + // All should return it. + all := bm.All() + if len(all) != 1 { + t.Errorf("All() = %d breakpoints, want 1", len(all)) + } + + // Clear it. + if err := bm.Clear(0x1000); err != nil { + t.Fatalf("Clear: %v", err) + } + if bm.At(0x1000) != nil { + t.Error("At(0x1000) after Clear should be nil") + } +} + +func TestBreakpointsSetWithCond(t *testing.T) { + tr := newMockTracer() + bm := NewBreakpoints(tr) + + cond := &Condition{Reg: "rax", Op: "==", Value: 42} + bp, err := bm.SetWithCond(0x2000, "cond_test", cond) + if err != nil { + t.Fatalf("SetWithCond: %v", err) + } + if bp.Cond == nil || bp.Cond.Value != 42 { + t.Error("condition not set") + } + + // Re-setting the same address should update the condition. + cond2 := &Condition{Reg: "rbx", Op: "<", Value: 100} + bp2, err := bm.SetWithCond(0x2000, "cond_test2", cond2) + if err != nil { + t.Fatalf("SetWithCond (update): %v", err) + } + if bp2.Cond.Value != 100 { + t.Error("condition not updated") + } + // Should have only 1 Peek (first Set), second is update (no Peek needed). + if len(tr.peeks) != 1 { + t.Errorf("expected 1 Peek, got %d", len(tr.peeks)) + } +} + +func TestBreakpointsClearAll(t *testing.T) { + tr := newMockTracer() + bm := NewBreakpoints(tr) + + bm.Set(0x1000, "a") + bm.Set(0x2000, "b") + bm.Set(0x3000, "c") + + if len(bm.All()) != 3 { + t.Fatalf("expected 3 breakpoints, got %d", len(bm.All())) + } + + bm.ClearAll() + if len(bm.All()) != 0 { + t.Errorf("ClearAll: expected 0 breakpoints, got %d", len(bm.All())) + } +} + +func TestBreakpointInfo(t *testing.T) { + tr := newMockTracer() + bm := NewBreakpoints(tr) + bm.Set(0x4000, "info_test") + + info := bm.Info() + if info == "" { + t.Error("Info returned empty string") + } + if !strings.Contains(info, "info_test") { + t.Errorf("Info %q does not contain label", info) + } +} diff --git a/debug/ptrace_linux_amd64.go b/debug/ptrace_linux_amd64.go index 7f5c506..174c053 100644 --- a/debug/ptrace_linux_amd64.go +++ b/debug/ptrace_linux_amd64.go @@ -19,38 +19,6 @@ import ( "unsafe" ) -// Regs holds the full general-purpose register set of a traced process -// (the Linux amd64 user_regs_struct layout). -type Regs struct { - R15 uint64 - R14 uint64 - R13 uint64 - R12 uint64 - RBP uint64 - RBX uint64 - R11 uint64 - R10 uint64 - R9 uint64 - R8 uint64 - RAX uint64 - RCX uint64 - RDX uint64 - RSI uint64 - RDI uint64 - OrigRAX uint64 - RIP uint64 - CS uint64 - RFLAGS uint64 - RSP uint64 - SS uint64 - FSBase uint64 - GSBase uint64 - DS uint64 - ES uint64 - FS uint64 - GS uint64 -} - // Session is a ptrace debugging session controlling one debuggee process. type Session struct { pid int diff --git a/debug/tracer.go b/debug/tracer.go new file mode 100644 index 0000000..e3c0c2a --- /dev/null +++ b/debug/tracer.go @@ -0,0 +1,90 @@ +// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) +// SPDX-License-Identifier: BSD-3-Clause + +package debug + +// Regs holds the full general-purpose register set of a traced process +// (the Linux amd64 user_regs_struct layout). +type Regs struct { + R15 uint64 + R14 uint64 + R13 uint64 + R12 uint64 + RBP uint64 + RBX uint64 + R11 uint64 + R10 uint64 + R9 uint64 + R8 uint64 + RAX uint64 + RCX uint64 + RDX uint64 + RSI uint64 + RDI uint64 + OrigRAX uint64 + RIP uint64 + CS uint64 + RFLAGS uint64 + RSP uint64 + SS uint64 + FSBase uint64 + GSBase uint64 + DS uint64 + ES uint64 + FS uint64 + GS uint64 +} + +// tracer abstracts the minimal ptrace operations needed by the breakpoint +// manager and the stop-information helpers. The live implementation is +// *Session (ptrace_linux_amd64.go); tests supply a mock. +type tracer interface { + Peek(addr uint64) (uint64, error) + Poke(addr uint64, val uint64) error + SetRegs(regs *Regs) error + Pid() int +} + +// mockTracer records Peek/Poke calls and provides fake register state. +type mockTracer struct { + mem map[uint64]byte + peeks []uint64 + pokes []struct { + addr uint64 + val uint64 + } + regs *Regs +} + +func newMockTracer() *mockTracer { + return &mockTracer{ + mem: make(map[uint64]byte), + regs: &Regs{}, + } +} + +func (m *mockTracer) Peek(addr uint64) (uint64, error) { + m.peeks = append(m.peeks, addr) + var val uint64 + for i := uint64(0); i < 8; i++ { + val |= uint64(m.mem[addr+i]) << (i * 8) + } + return val, nil +} + +func (m *mockTracer) Poke(addr uint64, val uint64) error { + m.pokes = append(m.pokes, struct { + addr uint64 + val uint64 + }{addr, val}) + for i := uint64(0); i < 8; i++ { + m.mem[addr+i] = byte(val >> (i * 8)) + } + return nil +} + +func (m *mockTracer) SetRegs(regs *Regs) error { + m.regs = regs + return nil +} +func (m *mockTracer) Pid() int { return 42 } diff --git a/justfile b/justfile index 2d8897d..b66fac3 100644 --- a/justfile +++ b/justfile @@ -3,7 +3,7 @@ # gasm-devkit — developer tooling for Go's Plan 9 assembler (GAsm). -version := "0.27.0" +version := "0.28.0" default: @just --list