ci: add Gitea CI workflows and release pipeline
Test / vet (push) Successful in 44s
Release / build (amd64, linux) (push) Successful in 39s
Release / build (arm64, linux) (push) Successful in 38s
Release / build (loong64, linux) (push) Successful in 39s
Release / build (riscv64, linux) (push) Successful in 39s
Test / test (push) Successful in 1m58s
Release / release (push) Successful in 18s
Test / build (push) Successful in 37s
Test / vet (push) Successful in 44s
Release / build (amd64, linux) (push) Successful in 39s
Release / build (arm64, linux) (push) Successful in 38s
Release / build (loong64, linux) (push) Successful in 39s
Release / build (riscv64, linux) (push) Successful in 39s
Test / test (push) Successful in 1m58s
Release / release (push) Successful in 18s
Test / build (push) Successful in 37s
Assisted-by: DeepSeek V4 Pro
This commit is contained in:
@@ -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."
|
||||||
@@ -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
|
||||||
@@ -9,6 +9,41 @@ and this project adheres to [Conventional Commits](https://www.conventionalcommi
|
|||||||
|
|
||||||
Unreleased changes on the `development` branch.
|
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
|
## [0.27.0] — 2026-08-01
|
||||||
|
|
||||||
Subprocess isolation for `--fuzz`: each function is fuzzed in its own child
|
Subprocess isolation for `--fuzz`: each function is fuzzed in its own child
|
||||||
|
|||||||
@@ -23,12 +23,14 @@ gasm verify dynamic analysis & verification (Phase 3)
|
|||||||
gasm debug source-level debugger (Phase 4)
|
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
|
> linter, formatter and language server) shipped in v0.1.0; Phase 2 (the
|
||||||
> standalone assembler — the full amd64 instruction set plus ELF, Mach-O
|
> standalone assembler — the full amd64 instruction set plus ELF, Mach-O
|
||||||
> and GOOBJ object emission) in v0.12.0; Phase 3 (dynamic analysis —
|
> and GOOBJ object emission) in v0.12.0; Phase 3 (dynamic analysis —
|
||||||
> JIT execution, differential testing, ABI checks and coverage profiling)
|
> 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
|
## Architecture support
|
||||||
|
|
||||||
|
|||||||
+64
-27
@@ -33,7 +33,7 @@ import (
|
|||||||
|
|
||||||
// version is the release version, stamped at build time via
|
// version is the release version, stamped at build time via
|
||||||
// -ldflags "-X main.version=…" (defaulting to the current release).
|
// -ldflags "-X main.version=…" (defaulting to the current release).
|
||||||
var version = "0.27.0"
|
var version = "0.28.0"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
if len(os.Args) < 2 {
|
if len(os.Args) < 2 {
|
||||||
@@ -73,39 +73,76 @@ func cmdVersion() int {
|
|||||||
return 0
|
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) {
|
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
|
fmt.Fprintf(w, "%sgasm %s%s — developer tooling for Go's Plan 9 assembler (GAsm)%s\n\n", bold, version, reset, reset)
|
||||||
language server for Plan 9 assembly into one self-contained binary.
|
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:
|
fmt.Fprintf(w, "%sUsage:%s\n", yellow, reset)
|
||||||
gasm <command> [arguments]
|
fmt.Fprintf(w, " gasm <command> [arguments]\n")
|
||||||
gasm [flags]
|
fmt.Fprintf(w, " gasm [flags]\n\n")
|
||||||
|
|
||||||
Commands:
|
fmt.Fprintf(w, "%sCommands:%s\n", yellow, reset)
|
||||||
tokens print the lexical token stream
|
commands := []struct{ name, desc string }{
|
||||||
parse parse and report syntax errors
|
{"tokens", "print the lexical token stream"},
|
||||||
fmt canonicalise formatting (gofmt for assembly)
|
{"parse", "parse and report syntax errors"},
|
||||||
lint run static checks
|
{"fmt", "canonicalise formatting (gofmt for assembly)"},
|
||||||
asm assemble .s files to machine code (amd64, riscv64)
|
{"lint", "run static checks"},
|
||||||
verify JIT-assemble and run dynamic checks (amd64, riscv64)
|
{"asm", "assemble .s files to machine code (amd64, riscv64)"},
|
||||||
lsp run the language server over stdio
|
{"verify", "JIT-assemble and run dynamic checks (amd64, riscv64)"},
|
||||||
version print the version (same as --version)
|
{"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:
|
fmt.Fprintf(w, "\n%sFlags:%s\n", yellow, reset)
|
||||||
-h, --help show this help
|
fmt.Fprintf(w, " %s-h, --help%s %sshow this help%s\n", cyan, reset, gray, reset)
|
||||||
-V, --version print the version
|
fmt.Fprintf(w, " %s-V, --version%s %sprint the version%s\n", cyan, reset, gray, reset)
|
||||||
|
|
||||||
Run "gasm <command> -h" for a command's usage and flags.
|
fmt.Fprintf(w, "\nRun \"gasm <command> -h\" for a command's usage and flags.\n\n")
|
||||||
|
|
||||||
Examples:
|
fmt.Fprintf(w, "%sExamples:%s\n", yellow, reset)
|
||||||
gasm fmt reformat every .s below the current directory
|
examples := []struct{ cmd, desc string }{
|
||||||
gasm lint go-flac/*.s run static checks over the kernels
|
{"gasm fmt", "reformat every .s below the current directory"},
|
||||||
gasm asm -o k.bin kern_amd64.s
|
{"gasm lint go-flac/*.s", "run static checks over the kernels"},
|
||||||
gasm asm --format elf -o k.o kern_amd64.s
|
{"gasm asm -o k.bin kern_amd64.s", ""},
|
||||||
gasm asm --format goobj -p pkg/path -o k.o kern_amd64.s
|
{"gasm asm --format elf -o k.o kern_amd64.s", ""},
|
||||||
`, version)
|
{"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
|
// newCommand returns the FlagSet of a subcommand whose -h/--help prints a
|
||||||
|
|||||||
+16
-17
@@ -1,8 +1,6 @@
|
|||||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
//go:build linux && amd64
|
|
||||||
|
|
||||||
package debug
|
package debug
|
||||||
|
|
||||||
import "fmt"
|
import "fmt"
|
||||||
@@ -85,14 +83,15 @@ func (c *Condition) Eval(regs *Regs) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Breakpoints manages the set of breakpoints for a Session.
|
// Breakpoints manages the set of breakpoints for a Session.
|
||||||
|
// Breakpoints manages software breakpoints for a debuggee.
|
||||||
type Breakpoints struct {
|
type Breakpoints struct {
|
||||||
s *Session
|
t tracer
|
||||||
bps map[uint64]*Breakpoint
|
bps map[uint64]*Breakpoint
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewBreakpoints creates a breakpoint manager for the session.
|
// NewBreakpoints creates a new breakpoint manager.
|
||||||
func NewBreakpoints(s *Session) *Breakpoints {
|
func NewBreakpoints(t tracer) *Breakpoints {
|
||||||
return &Breakpoints{s: s, bps: make(map[uint64]*Breakpoint)}
|
return &Breakpoints{t: t, bps: make(map[uint64]*Breakpoint)}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set installs a breakpoint at addr (replaces any existing one).
|
// 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
|
return bp, nil
|
||||||
}
|
}
|
||||||
// Read the original byte.
|
// Read the original byte.
|
||||||
word, err := bm.s.Peek(addr)
|
word, err := bm.t.Peek(addr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
orig := byte(word)
|
orig := byte(word)
|
||||||
// Patch with INT3 (0xCC), preserving the rest of the word.
|
// Patch with INT3 (0xCC), preserving the rest of the word.
|
||||||
patched := (word &^ 0xFF) | 0xCC
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
bp := &Breakpoint{Addr: addr, Label: label, Orig: orig, Enabled: true, Cond: cond}
|
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 {
|
if !ok {
|
||||||
return fmt.Errorf("debug: no breakpoint at %#x", addr)
|
return fmt.Errorf("debug: no breakpoint at %#x", addr)
|
||||||
}
|
}
|
||||||
word, err := bm.s.Peek(addr)
|
word, err := bm.t.Peek(addr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
restored := (word &^ 0xFF) | uint64(bp.Orig)
|
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
|
return err
|
||||||
}
|
}
|
||||||
delete(bm.bps, addr)
|
delete(bm.bps, addr)
|
||||||
@@ -211,24 +210,24 @@ func (bm *Breakpoints) HandleTrap(regs *Regs) *Breakpoint {
|
|||||||
if bp.Cond != nil && !bp.Cond.Eval(regs) {
|
if bp.Cond != nil && !bp.Cond.Eval(regs) {
|
||||||
// Condition not met — restore the byte but do NOT rewind RIP.
|
// Condition not met — restore the byte but do NOT rewind RIP.
|
||||||
// The process continues from the next instruction (past the INT3).
|
// 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 {
|
if err == nil {
|
||||||
restored := (word &^ 0xFF) | uint64(bp.Orig)
|
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.
|
// RIP is already past the INT3 (trapAddr + 1). Don't rewind.
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
bp.hits++
|
bp.hits++
|
||||||
// Restore the original byte.
|
// Restore the original byte.
|
||||||
word, err := bm.s.Peek(trapAddr)
|
word, err := bm.t.Peek(trapAddr)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
restored := (word &^ 0xFF) | uint64(bp.Orig)
|
restored := (word &^ 0xFF) | uint64(bp.Orig)
|
||||||
bm.s.Poke(trapAddr, restored)
|
bm.t.Poke(trapAddr, restored)
|
||||||
}
|
}
|
||||||
// Rewind RIP to re-execute the original instruction.
|
// Rewind RIP to re-execute the original instruction.
|
||||||
regs.RIP = trapAddr
|
regs.RIP = trapAddr
|
||||||
bm.s.SetRegs(regs)
|
bm.t.SetRegs(regs)
|
||||||
return bp
|
return bp
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,10 +239,10 @@ func (bm *Breakpoints) Reinsert(addr uint64) error {
|
|||||||
if !ok || !bp.Enabled {
|
if !ok || !bp.Enabled {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
word, err := bm.s.Peek(addr)
|
word, err := bm.t.Peek(addr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
patched := (word &^ 0xFF) | 0xCC
|
patched := (word &^ 0xFF) | 0xCC
|
||||||
return bm.s.Poke(addr, patched)
|
return bm.t.Poke(addr, patched)
|
||||||
}
|
}
|
||||||
|
|||||||
+110
-3
@@ -1,11 +1,12 @@
|
|||||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
//go:build linux && amd64
|
|
||||||
|
|
||||||
package debug
|
package debug
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
func TestConditionEval(t *testing.T) {
|
func TestConditionEval(t *testing.T) {
|
||||||
regs := &Regs{
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,38 +19,6 @@ import (
|
|||||||
"unsafe"
|
"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.
|
// Session is a ptrace debugging session controlling one debuggee process.
|
||||||
type Session struct {
|
type Session struct {
|
||||||
pid int
|
pid int
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (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 }
|
||||||
Reference in New Issue
Block a user