diff --git a/.golangci.yml b/.golangci.yml index 7d06b4f..419fd6f 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -7,7 +7,7 @@ formatters: settings: goimports: local-prefixes: - - memodroid + - memdroid linters: enable: diff --git a/docs/api.md b/docs/api.md index 78d6de3..a2dd664 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1,9 +1,21 @@ # API Reference All REST endpoints accept and return JSON. POST endpoints read the request body as JSON. -The WebSocket endpoint is at `/ws/watch`. +The WebSocket endpoint is at `/ws/watch`. Each endpoint is gated to a single +HTTP method (GET for reads, POST for mutations); other methods return `405`. +Request bodies are capped at 1 MiB. -Base URL: `http://localhost:8080` +Base URL: `http://localhost:8080` (the server binds to loopback by default; +use `-addr` to change it). + +## Authentication + +By default there is no auth and the server listens only on `127.0.0.1`. To +expose it safely, start with `-token ` (or set `MEMDROID_TOKEN`). All +`/api` and `/ws` requests must then present the token via a `token` query +parameter, an `Authorization: Bearer ` header, or the `mdtoken` cookie. +Opening `http://:/?token=` once sets the cookie so the Web +UI keeps working. ## Status @@ -57,7 +69,11 @@ Value types: `int32`, `int64`, `float32`, `float64`, `uint32`, `uint64`, `bytes` |--------|------|------|-------------| | POST | `/api/pointer/scan` | `{"addr":"0x7f...","max_depth":5,"max_offset":2048}` | Find pointer chains to address | -Response: `{"target":"0x...","chains":[{"base":"0x...","label":"libXX.so","offsets":[32,8],"path":"[libXX.so+0x1234]+0x20+0x8"}]}` +Response: `{"target":"0x...","chains":[{"base":"0x...","label":"libXX.so","base_offset":"0x1234","offsets":[32,8],"path":"[libXX.so+0x1234]+0x20+0x8"}]}` + +Offsets are in base→final application order. `/api/pointer/resolve` takes +`{"label":"libXX.so","base_offset":"0x1234","offsets":[32,8]}` and walks the +chain in the current process, returning `{"resolved":"0x..."}`. ## Memory diff --git a/docs/architecture.md b/docs/architecture.md index 67b4fc0..9983aa3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -3,7 +3,7 @@ ## Overview ``` -./memodroid +./memdroid ├── CLI (main goroutine) — interactive menu ├── HTTP server (:8080) — Web UI + REST API + WebSocket └── app.State (shared, mutex) @@ -46,44 +46,47 @@ internal/ │ ├── pattern.go # Byte pattern search with ?? wildcard, chunked reads │ └── string.go # UTF-8 / UTF-16LE string search via SearchPattern ├── pointer/ - │ └── pointer.go # Pointer chain scan — find stable base+offset paths + │ ├── pointer.go # Pointer chain scan — find stable base+offset paths + │ └── resolve.go # Re-resolve a saved chain against a new run ├── modify/ │ ├── modify.go # Write value / string to address - │ ├── undo.go # Save previous value before modify, revert stack - │ ├── freeze.go # Background goroutine to hold values + FreezeAll - │ └── dump.go # Hex dump memory region to file + │ ├── undo.go # Save previous value before modify, revert stack (mutex-guarded) + │ ├── freeze.go # Freezer — periodic re-write, backed by poller.Pool + │ ├── snapshot.go # Capture / diff a memory region + │ └── dump.go # Hex dump memory region to file (bulk ReadRegion) ├── watch/ - │ └── watch.go # Background goroutine; notifies on value change via BroadcastFunc + │ ├── watch.go # Watcher — poll for value change, backed by poller.Pool + │ └── alert.go # AlertWatcher — conditional watch + auto-write └── store/ ├── bookmark.go # Named address bookmarks with bulk modify - └── save.go # Save / Load JSON state (bookmarks + candidates) + ├── cheatengine.go # Import CheatEngine .CT tables as bookmarks + └── save.go # Save / Load versioned JSON state (bookmarks + candidates) + +internal/poller/ +└── poller.go # Keyed goroutine manager shared by Freezer/Watcher/AlertWatcher ``` ## CLI Source Layout -The root-level `package main` is split into focused files: - -``` -main.go # main() entry point, REPL dispatch loop, printMenu() -cli_helpers.go # prompt(), parseAddr(), parseValue(), require* guards -cli_device.go # handleSelectDevice, handleConnectWifi, handleDisconnectWifi -cli_process.go # doAttach, handleAttach, handleAttachByName, handleDetach -cli_search.go # handleSetValueType, handleSearchFiltered, handleShowCandidates -cli_memory.go # handleWatch, handleDump, handlePointerScan, handleShowMaps, handleBookmarkList -``` +The interactive menu lives in `package cli` under `internal/cli/`, one file per +concern (`run.go` dispatch loop, `prompt.go` input helpers, `device.go`, +`process.go`, `search.go`, `memory.go`, `pointer.go`, `alert.go`, +`bookmarks.go`, `menu.go`). `main.go` wires flags, the ADB driver, shared +state, and the HTTP server, then hands off to `cli.Run`. ## Dependency Graph ``` -main (cli_*.go) +main ├── app.State + ├── cli ├── driver/adb (no internal deps — exec.Command("adb", ...) only) ├── process → driver - ├── server → app, driver/adb, memory/*, server/wswatch + ├── server → app, driver, driver/adb, memory/*, server/wswatch ├── memory/search → driver ├── memory/pointer → driver - ├── memory/modify → driver, memory/search - ├── memory/watch → driver, memory/search + ├── memory/modify → driver, memory/search, poller + ├── memory/watch → driver, memory/search, poller └── memory/store → memory/search ``` diff --git a/docs/usage.md b/docs/usage.md index 16e4083..7f34d93 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -9,7 +9,7 @@ ```bash go build -o memdroid . -./memodroid +./memdroid ``` No root on the host is required. All privileged operations run on the device via `adb shell su`. @@ -17,7 +17,7 @@ No root on the host is required. All privileged operations run on the device via ## Connecting a Device ### USB -Connect the device and run MemoDroid. It auto-selects if only one device is found. +Connect the device and run memdroid. It auto-selects if only one device is found. If multiple devices are connected, you will be prompted to select one. ### Wi-Fi ADB @@ -157,7 +157,7 @@ Switch type with menu `6`. Changing type resets the search session. ## Web UI -Open `http://localhost:8080` while MemoDroid is running. All features are available. +Open `http://localhost:8080` while memdroid is running. All features are available. Special Web UI features: - **Watch panel**: real-time value change stream via WebSocket diff --git a/go.mod b/go.mod index 7544ac9..4793fdf 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module memodroid +module memdroid go 1.26 diff --git a/internal/app/state.go b/internal/app/state.go index 315cb53..886464e 100644 --- a/internal/app/state.go +++ b/internal/app/state.go @@ -3,11 +3,11 @@ package app import ( "sync" - "memodroid/internal/driver" - "memodroid/internal/memory/modify" - "memodroid/internal/memory/search" - "memodroid/internal/memory/store" - "memodroid/internal/memory/watch" + "memdroid/internal/driver" + "memdroid/internal/memory/modify" + "memdroid/internal/memory/search" + "memdroid/internal/memory/store" + "memdroid/internal/memory/watch" ) // AttachedProcess holds info about an attached process. diff --git a/internal/cli/alert.go b/internal/cli/alert.go index 37d7e7a..5359bd7 100644 --- a/internal/cli/alert.go +++ b/internal/cli/alert.go @@ -4,8 +4,8 @@ import ( "fmt" "time" - "memodroid/internal/app" - "memodroid/internal/memory/watch" + "memdroid/internal/app" + "memdroid/internal/memory/watch" ) // DefaultAlertPollInterval is the default polling interval for alert watches. diff --git a/internal/cli/bookmarks.go b/internal/cli/bookmarks.go index d3d5609..81d4a04 100644 --- a/internal/cli/bookmarks.go +++ b/internal/cli/bookmarks.go @@ -3,8 +3,8 @@ package cli import ( "fmt" - "memodroid/internal/app" - "memodroid/internal/memory/store" + "memdroid/internal/app" + "memdroid/internal/memory/store" ) func BookmarkList(st *app.State) { diff --git a/internal/cli/device.go b/internal/cli/device.go index ceb29b9..e2cc0fa 100644 --- a/internal/cli/device.go +++ b/internal/cli/device.go @@ -4,7 +4,7 @@ import ( "fmt" "strconv" - "memodroid/internal/driver/adb" + "memdroid/internal/driver/adb" ) func SelectDevice(d *adb.ADB) { diff --git a/internal/cli/memory.go b/internal/cli/memory.go index 5515f95..f4d6889 100644 --- a/internal/cli/memory.go +++ b/internal/cli/memory.go @@ -5,8 +5,8 @@ import ( "strconv" "time" - "memodroid/internal/app" - "memodroid/internal/memory/modify" + "memdroid/internal/app" + "memdroid/internal/memory/modify" ) // DefaultWatchInterval is the default polling interval when watching an address. diff --git a/internal/cli/menu.go b/internal/cli/menu.go index 6fc90ac..13d8b96 100644 --- a/internal/cli/menu.go +++ b/internal/cli/menu.go @@ -3,12 +3,12 @@ package cli import ( "fmt" - "memodroid/internal/app" - "memodroid/internal/driver/adb" + "memdroid/internal/app" + "memdroid/internal/driver/adb" ) -// ServerAddr is displayed in the menu header. Set by the caller before rendering. -var ServerAddr = ":8080" +// ServerURL is displayed in the menu header. Set by the caller before rendering. +var ServerURL = "http://localhost:8080" type menuSection struct { title string @@ -96,7 +96,7 @@ func PrintMenu(st *app.State, d *adb.ADB) { vt := st.GetValueType() sess := st.GetSession() - fmt.Println("\n=== MemoDroid ===") + fmt.Println("\n=== memdroid ===") serial := d.DeviceSerial() if serial == "" { serial = "(none)" @@ -112,7 +112,7 @@ func PrintMenu(st *app.State, d *adb.ADB) { } fmt.Println() } - fmt.Printf("Web UI: http://localhost%s\n", ServerAddr) + fmt.Printf("Web UI: %s\n", ServerURL) for _, section := range menu { fmt.Printf("--- %s ---\n", section.title) for _, it := range section.items { diff --git a/internal/cli/pointer.go b/internal/cli/pointer.go index 1c1a0fb..fc53e46 100644 --- a/internal/cli/pointer.go +++ b/internal/cli/pointer.go @@ -5,8 +5,8 @@ import ( "strconv" "strings" - "memodroid/internal/app" - "memodroid/internal/memory/pointer" + "memdroid/internal/app" + "memdroid/internal/memory/pointer" ) func PointerScan(st *app.State) { @@ -50,6 +50,15 @@ func PointerResolve(st *app.State) { fmt.Println("Module name required") return } + var baseOffset uintptr + if s := Prompt("Base offset (hex, e.g. 0x1234): "); s != "" { + v, err := strconv.ParseUint(strings.TrimPrefix(s, "0x"), 16, 64) + if err != nil { + fmt.Println("Invalid base offset") + return + } + baseOffset = uintptr(v) + } offsetsStr := Prompt("Offsets (comma-separated hex, e.g. 0x10,0x20,0x8): ") if offsetsStr == "" { fmt.Println("Offsets required") @@ -61,8 +70,9 @@ func PointerResolve(st *app.State) { return } chain := pointer.Chain{ - BaseLabel: label, - Offsets: offsets, + BaseLabel: label, + BaseOffset: baseOffset, + Offsets: offsets, } resolved, err := pointer.ResolveChain(st.GetDriver(), st.GetPID(), chain) if err != nil { diff --git a/internal/cli/process.go b/internal/cli/process.go index 4bfe1e7..9a778e4 100644 --- a/internal/cli/process.go +++ b/internal/cli/process.go @@ -4,9 +4,9 @@ import ( "fmt" "strconv" - "memodroid/internal/app" - "memodroid/internal/driver/adb" - "memodroid/internal/memory/search" + "memdroid/internal/app" + "memdroid/internal/driver/adb" + "memdroid/internal/memory/search" ) func doAttach(st *app.State, pid int, name string) { diff --git a/internal/cli/prompt.go b/internal/cli/prompt.go index d7b68ee..f82c67b 100644 --- a/internal/cli/prompt.go +++ b/internal/cli/prompt.go @@ -7,7 +7,7 @@ import ( "strconv" "strings" - "memodroid/internal/memory/search" + "memdroid/internal/memory/search" ) var stdinReader = bufio.NewReader(os.Stdin) diff --git a/internal/cli/run.go b/internal/cli/run.go index 9d4f29d..3cff400 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -5,13 +5,13 @@ import ( "os" "strconv" - "memodroid/internal/app" - "memodroid/internal/driver" - "memodroid/internal/driver/adb" - "memodroid/internal/memory/modify" - "memodroid/internal/memory/search" - "memodroid/internal/memory/store" - "memodroid/internal/process" + "memdroid/internal/app" + "memdroid/internal/driver" + "memdroid/internal/driver/adb" + "memdroid/internal/memory/modify" + "memdroid/internal/memory/search" + "memdroid/internal/memory/store" + "memdroid/internal/process" ) // DefaultStateFile is the default filename used by Save/Load State. @@ -324,7 +324,7 @@ func modifyAt(st *app.State, drv driver.Driver, pid int, vt search.ValueType) { if !ok { return } - if err := st.UndoStack.WithUndo(drv, pid, addr, val, vt); err != nil { + if err := st.UndoStack.WithUndo(drv, pid, addr, val); err != nil { fmt.Printf("Modify failed: %v\n", err) } else { fmt.Printf("Modified 0x%x (undo available, depth: %d)\n", addr, st.UndoStack.Depth()) @@ -382,8 +382,8 @@ func loadSession(st *app.State) { if path == "" { path = DefaultStateFile } - loaded := st.GetSession() - if err := store.LoadState(path, st.GetBookmarks(), &loaded); err != nil { + loaded, err := store.LoadState(path, st.GetBookmarks()) + if err != nil { fmt.Printf("Load failed: %v\n", err) return } diff --git a/internal/cli/search.go b/internal/cli/search.go index b8c38fd..94b78a3 100644 --- a/internal/cli/search.go +++ b/internal/cli/search.go @@ -3,9 +3,9 @@ package cli import ( "fmt" - "memodroid/internal/app" - "memodroid/internal/driver" - "memodroid/internal/memory/search" + "memdroid/internal/app" + "memdroid/internal/driver" + "memdroid/internal/memory/search" ) // MaxCandidatesDisplay caps how many search candidates are printed. diff --git a/internal/driver/adb/adb.go b/internal/driver/adb/adb.go index 72d543e..e5dc663 100644 --- a/internal/driver/adb/adb.go +++ b/internal/driver/adb/adb.go @@ -9,7 +9,7 @@ import ( "strings" "sync" - "memodroid/internal/driver" + "memdroid/internal/driver" ) // execErr enriches an exec error with the stderr output from the failed command. diff --git a/internal/driver/adb/maps.go b/internal/driver/adb/maps.go index daf8c4a..583b0f6 100644 --- a/internal/driver/adb/maps.go +++ b/internal/driver/adb/maps.go @@ -6,7 +6,7 @@ import ( "fmt" "strings" - "memodroid/internal/driver" + "memdroid/internal/driver" ) // ReadMaps returns all rw memory regions for pid. diff --git a/internal/driver/adb/mem.go b/internal/driver/adb/mem.go index 7108662..6995742 100644 --- a/internal/driver/adb/mem.go +++ b/internal/driver/adb/mem.go @@ -6,19 +6,15 @@ import ( "strings" ) -// Peek reads size bytes from addr in /proc//mem via adb exec-out + dd. -// The output is piped through base64 to survive adb's text transport. +// Peek reads size bytes from addr in /proc//mem via a root dd, piped +// through base64 to survive adb's text transport. func (a *ADB) Peek(pid int, addr uintptr, size int) ([]byte, error) { - // base64 wrapping avoids binary corruption over adb shell text mode. - cmd := fmt.Sprintf( - "su -c 'dd if=/proc/%d/mem bs=1 skip=%d count=%d 2>/dev/null | base64'", - pid, addr, size, - ) - out, err := a.shell("sh", "-c", cmd) + cmd := fmt.Sprintf("dd if=/proc/%d/mem bs=1 skip=%d count=%d 2>/dev/null | base64", pid, addr, size) + out, err := a.shellRoot(cmd) if err != nil { return nil, fmt.Errorf("peek 0x%x: %w", addr, err) } - decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(out))) + decoded, err := decodeB64(out) if err != nil { return nil, fmt.Errorf("peek decode 0x%x: %w", addr, err) } @@ -28,16 +24,15 @@ func (a *ADB) Peek(pid int, addr uintptr, size int) ([]byte, error) { return decoded, nil } -// Poke writes data to addr in /proc//mem via adb exec-out + dd. -// The payload is transmitted as base64 and decoded on-device. +// Poke writes data to addr in /proc//mem via a root dd. The payload is +// transmitted as base64 and decoded on-device. func (a *ADB) Poke(pid int, addr uintptr, data []byte) error { b64 := base64.StdEncoding.EncodeToString(data) cmd := fmt.Sprintf( "echo %s | base64 -d | dd of=/proc/%d/mem bs=1 seek=%d count=%d conv=notrunc 2>/dev/null", b64, pid, addr, len(data), ) - _, err := a.shellRoot(cmd) - if err != nil { + if _, err := a.shellRoot(cmd); err != nil { return fmt.Errorf("poke 0x%x: %w", addr, err) } return nil @@ -75,14 +70,14 @@ func (a *ADB) ReadRegion(pid int, addr uintptr, size int) ([]byte, error) { } cur := addr + uintptr(len(out)) cmd := fmt.Sprintf( - "su -c 'dd if=/proc/%d/mem bs=4096 skip=%d count=%d iflag=skip_bytes,count_bytes 2>/dev/null | base64'", + "dd if=/proc/%d/mem bs=4096 skip=%d count=%d iflag=skip_bytes,count_bytes 2>/dev/null | base64", pid, cur, rem, ) - raw, err := a.shell("sh", "-c", cmd) + raw, err := a.shellRoot(cmd) if err != nil { return nil, fmt.Errorf("read region 0x%x+%d: %w", cur, rem, err) } - decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(raw))) + decoded, err := decodeB64(raw) if err != nil { return nil, fmt.Errorf("read region decode 0x%x: %w", cur, err) } @@ -91,9 +86,12 @@ func (a *ADB) ReadRegion(pid int, addr uintptr, size int) ([]byte, error) { } out = append(out, decoded...) if len(decoded) < rem { - // Short read — reached end of readable area. - break + break // short read — reached end of readable area } } return out, nil } + +func decodeB64(raw []byte) ([]byte, error) { + return base64.StdEncoding.DecodeString(strings.TrimSpace(string(raw))) +} diff --git a/internal/driver/adb/process.go b/internal/driver/adb/process.go index 7f360b7..9fbe704 100644 --- a/internal/driver/adb/process.go +++ b/internal/driver/adb/process.go @@ -2,10 +2,11 @@ package adb import ( "fmt" + "os" "strconv" "strings" - "memodroid/internal/driver" + "memdroid/internal/driver" ) // ListProcesses returns all running processes via "adb shell ps -A". @@ -47,12 +48,17 @@ func (a *ADB) FindProcessByName(substr string) ([]driver.ProcessInfo, error) { return out, nil } +// signal sends a POSIX signal to pid via "kill - " as root. +func (a *ADB) signal(pid int, sig string) error { + _, err := a.shellRoot(fmt.Sprintf("kill -%s %d", sig, pid)) + return err +} + // Attach sends SIGSTOP to the process to pause it before memory operations. // On Android we don't use ptrace; instead we stop the process with kill -STOP // which requires root. func (a *ADB) Attach(pid int) error { - _, err := a.shellRoot(fmt.Sprintf("kill -STOP %d", pid)) - if err != nil { + if err := a.signal(pid, "STOP"); err != nil { return fmt.Errorf("attach (SIGSTOP) pid %d: %w", pid, err) } return nil @@ -60,13 +66,14 @@ func (a *ADB) Attach(pid int) error { // Detach resumes the process with SIGCONT. func (a *ADB) Detach(pid int) { - _, _ = a.shellRoot(fmt.Sprintf("kill -CONT %d", pid)) + if err := a.signal(pid, "CONT"); err != nil { + _, _ = fmt.Fprintf(os.Stderr, "warning: detach (SIGCONT) pid %d failed: %v\n", pid, err) + } } // Stop sends SIGSTOP (same as Attach, exposed as an explicit control command). func (a *ADB) Stop(pid int) error { - _, err := a.shellRoot(fmt.Sprintf("kill -STOP %d", pid)) - if err != nil { + if err := a.signal(pid, "STOP"); err != nil { return fmt.Errorf("stop pid %d: %w", pid, err) } return nil @@ -74,8 +81,7 @@ func (a *ADB) Stop(pid int) error { // Continue sends SIGCONT to resume a stopped process. func (a *ADB) Continue(pid int) error { - _, err := a.shellRoot(fmt.Sprintf("kill -CONT %d", pid)) - if err != nil { + if err := a.signal(pid, "CONT"); err != nil { return fmt.Errorf("continue pid %d: %w", pid, err) } return nil diff --git a/internal/memory/modify/dump.go b/internal/memory/modify/dump.go index 92bda2c..38c7272 100644 --- a/internal/memory/modify/dump.go +++ b/internal/memory/modify/dump.go @@ -1,59 +1,67 @@ package modify import ( + "bufio" "fmt" "os" - "memodroid/internal/driver" + "memdroid/internal/driver" ) const ( - dumpWordSize = 8 - dumpASCIIMin = 0x20 - dumpASCIIMax = 0x7e + dumpLineWidth = 16 + dumpASCIIMin = 0x20 + dumpASCIIMax = 0x7e ) -// DumpRegion reads size bytes from addr and writes a hex dump to path. +// DumpRegion reads size bytes from addr in one bulk transfer and writes a +// classic 16-byte-per-line hex dump to path. func DumpRegion(drv driver.Driver, pid int, addr uintptr, size int, path string) error { + if size <= 0 { + return fmt.Errorf("dump size must be positive, got %d", size) + } + + data, err := drv.ReadRegion(pid, addr, size) + if err != nil { + return fmt.Errorf("dump 0x%x: %w", addr, err) + } + f, err := os.Create(path) if err != nil { return err } defer func() { _ = f.Close() }() - for offset := 0; offset < size; offset += dumpWordSize { - remaining := size - offset - if remaining > dumpWordSize { - remaining = dumpWordSize - } - - data, err := drv.Peek(pid, addr+uintptr(offset), remaining) - if err != nil { - _, _ = fmt.Fprintf(f, "%016x !! read error: %v\n", addr+uintptr(offset), err) - continue + w := bufio.NewWriter(f) + for offset := 0; offset < len(data); offset += dumpLineWidth { + end := offset + dumpLineWidth + if end > len(data) { + end = len(data) } + writeHexLine(w, addr+uintptr(offset), data[offset:end]) + } + return w.Flush() +} - _, _ = fmt.Fprintf(f, "%016x ", addr+uintptr(offset)) - for i, b := range data { - _, _ = fmt.Fprintf(f, "%02x ", b) - if i == dumpWordSize/2-1 { - _, _ = fmt.Fprintf(f, " ") - } +func writeHexLine(w *bufio.Writer, addr uintptr, chunk []byte) { + _, _ = fmt.Fprintf(w, "%016x ", addr) + for i := 0; i < dumpLineWidth; i++ { + if i < len(chunk) { + _, _ = fmt.Fprintf(w, "%02x ", chunk[i]) + } else { + _, _ = w.WriteString(" ") } - for i := len(data); i < dumpWordSize; i++ { - _, _ = fmt.Fprintf(f, " ") + if i == dumpLineWidth/2-1 { + _, _ = w.WriteString(" ") } - _, _ = fmt.Fprintf(f, " |") - for _, b := range data { - if b >= dumpASCIIMin && b <= dumpASCIIMax { - _, _ = fmt.Fprintf(f, "%c", b) - } else { - _, _ = fmt.Fprintf(f, ".") - } + } + _, _ = w.WriteString(" |") + for _, b := range chunk { + if b >= dumpASCIIMin && b <= dumpASCIIMax { + _ = w.WriteByte(b) + } else { + _ = w.WriteByte('.') } - _, _ = fmt.Fprintf(f, "|\n") } - - fmt.Printf("Dumped %d bytes from 0x%x to %s\n", size, addr, path) - return nil + _, _ = w.WriteString("|\n") } diff --git a/internal/memory/modify/freeze.go b/internal/memory/modify/freeze.go index b8bc463..6a5c152 100644 --- a/internal/memory/modify/freeze.go +++ b/internal/memory/modify/freeze.go @@ -1,34 +1,28 @@ package modify import ( - "fmt" "sync" "time" - "memodroid/internal/driver" - "memodroid/internal/memory/search" + "memdroid/internal/driver" + "memdroid/internal/memory/search" + "memdroid/internal/poller" ) const defaultFreezeInterval = 100 * time.Millisecond -type freezeEntry struct { - drv driver.Driver - pid int - addr uintptr - value []byte - stop chan struct{} -} - -// Freezer manages a set of frozen addresses. +// Freezer manages a set of frozen addresses, each periodically re-written to +// hold its value. Task lifecycle is delegated to poller.Pool. type Freezer struct { + pool *poller.Pool + mu sync.Mutex - entries map[uintptr]*freezeEntry interval time.Duration } func NewFreezer() *Freezer { return &Freezer{ - entries: make(map[uintptr]*freezeEntry), + pool: poller.New(), interval: defaultFreezeInterval, } } @@ -47,43 +41,24 @@ func (f *Freezer) GetInterval() time.Duration { return f.interval } -// Freeze starts a goroutine that repeatedly writes value to addr at the configured interval. -// Returns an error if addr is already frozen. +// Freeze starts a goroutine that repeatedly writes value to addr at the +// configured interval. Returns an error if addr is already frozen. func (f *Freezer) Freeze(drv driver.Driver, pid int, addr uintptr, value []byte) error { - f.mu.Lock() - defer f.mu.Unlock() - - if _, exists := f.entries[addr]; exists { - return fmt.Errorf("0x%x is already frozen", addr) - } - val := make([]byte, len(value)) copy(val, value) + iv := f.GetInterval() - iv := f.interval - e := &freezeEntry{drv: drv, pid: pid, addr: addr, value: val, stop: make(chan struct{})} - f.entries[addr] = e - - go func() { - ticker := time.NewTicker(iv) - defer ticker.Stop() - for { - select { - case <-ticker.C: - _ = e.drv.Poke(e.pid, e.addr, e.value) - case <-e.stop: - return - } - } - }() - - return nil + return f.pool.Start(addr, func(stop <-chan struct{}) { + poller.EveryTick(iv, stop, func() { + _ = drv.Poke(pid, addr, val) + }) + }) } // FreezeAllCandidates freezes every address in the session with its last-seen value. func (f *Freezer) FreezeAllCandidates(drv driver.Driver, s *search.Session) int { count := 0 - for addr, val := range s.Candidates { + for addr, val := range s.Snapshot() { if f.Freeze(drv, s.PID, addr, val) == nil { count++ } @@ -92,39 +67,10 @@ func (f *Freezer) FreezeAllCandidates(drv driver.Driver, s *search.Session) int } // Unfreeze stops the freeze goroutine for addr. -// Returns an error if addr was not frozen. -func (f *Freezer) Unfreeze(addr uintptr) error { - f.mu.Lock() - defer f.mu.Unlock() - - e, ok := f.entries[addr] - if !ok { - return fmt.Errorf("0x%x is not frozen", addr) - } - close(e.stop) - delete(f.entries, addr) - return nil -} +func (f *Freezer) Unfreeze(addr uintptr) error { return f.pool.Stop(addr) } // UnfreezeAll stops all freeze goroutines. -func (f *Freezer) UnfreezeAll() { - f.mu.Lock() - defer f.mu.Unlock() - - for addr, e := range f.entries { - close(e.stop) - delete(f.entries, addr) - } -} +func (f *Freezer) UnfreezeAll() { f.pool.StopAll() } // List returns all currently frozen addresses. -func (f *Freezer) List() []uintptr { - f.mu.Lock() - defer f.mu.Unlock() - - addrs := make([]uintptr, 0, len(f.entries)) - for addr := range f.entries { - addrs = append(addrs, addr) - } - return addrs -} +func (f *Freezer) List() []uintptr { return f.pool.List() } diff --git a/internal/memory/modify/modify.go b/internal/memory/modify/modify.go index 85033e0..dfa9227 100644 --- a/internal/memory/modify/modify.go +++ b/internal/memory/modify/modify.go @@ -1,6 +1,6 @@ package modify -import "memodroid/internal/driver" +import "memdroid/internal/driver" // Write writes value bytes to addr in the target process. func Write(drv driver.Driver, pid int, addr uintptr, value []byte) error { diff --git a/internal/memory/modify/snapshot.go b/internal/memory/modify/snapshot.go index b87f3f0..0086028 100644 --- a/internal/memory/modify/snapshot.go +++ b/internal/memory/modify/snapshot.go @@ -4,7 +4,7 @@ import ( "fmt" "os" - "memodroid/internal/driver" + "memdroid/internal/driver" ) // Snapshot holds a captured memory region for comparison. diff --git a/internal/memory/modify/undo.go b/internal/memory/modify/undo.go index 171927f..46ed22a 100644 --- a/internal/memory/modify/undo.go +++ b/internal/memory/modify/undo.go @@ -2,9 +2,9 @@ package modify import ( "fmt" + "sync" - "memodroid/internal/driver" - "memodroid/internal/memory/search" + "memdroid/internal/driver" ) const maxUndoDepth = 50 @@ -14,11 +14,12 @@ type undoEntry struct { pid int addr uintptr value []byte - vtype search.ValueType } -// UndoStack records previous values so writes can be reverted. +// UndoStack records previous values so writes can be reverted. It is safe for +// concurrent use. type UndoStack struct { + mu sync.Mutex entries []undoEntry } @@ -27,7 +28,7 @@ func NewUndoStack() *UndoStack { } // WithUndo writes value to addr, saving the previous value for Undo. -func (u *UndoStack) WithUndo(drv driver.Driver, pid int, addr uintptr, value []byte, vt search.ValueType) error { +func (u *UndoStack) WithUndo(drv driver.Driver, pid int, addr uintptr, value []byte) error { prev, err := drv.Peek(pid, addr, len(value)) if err != nil { return err @@ -35,7 +36,9 @@ func (u *UndoStack) WithUndo(drv driver.Driver, pid int, addr uintptr, value []b if err := drv.Poke(pid, addr, value); err != nil { return err } - u.entries = append(u.entries, undoEntry{drv: drv, pid: pid, addr: addr, value: prev, vtype: vt}) + u.mu.Lock() + defer u.mu.Unlock() + u.entries = append(u.entries, undoEntry{drv: drv, pid: pid, addr: addr, value: prev}) if len(u.entries) > maxUndoDepth { u.entries = u.entries[len(u.entries)-maxUndoDepth:] } @@ -44,23 +47,28 @@ func (u *UndoStack) WithUndo(drv driver.Driver, pid int, addr uintptr, value []b // Undo reverts the most recent WithUndo. Returns an error if the stack is empty. func (u *UndoStack) Undo() error { + u.mu.Lock() if len(u.entries) == 0 { + u.mu.Unlock() return fmt.Errorf("nothing to undo") } e := u.entries[len(u.entries)-1] u.entries = u.entries[:len(u.entries)-1] - if err := e.drv.Poke(e.pid, e.addr, e.value); err != nil { - return err - } - return nil + u.mu.Unlock() + + return e.drv.Poke(e.pid, e.addr, e.value) } // Depth returns how many undo steps are available. func (u *UndoStack) Depth() int { + u.mu.Lock() + defer u.mu.Unlock() return len(u.entries) } // Clear discards the undo history. func (u *UndoStack) Clear() { + u.mu.Lock() + defer u.mu.Unlock() u.entries = nil } diff --git a/internal/memory/pointer/pointer.go b/internal/memory/pointer/pointer.go index 4843f88..2aeb030 100644 --- a/internal/memory/pointer/pointer.go +++ b/internal/memory/pointer/pointer.go @@ -6,19 +6,24 @@ // // Algorithm: // 1. Collect all pointer-aligned values in rw regions that fall within the -// mapped address space ("candidate pointers"). +// mapped address space ("candidate pointers"), mapping each pointed-to +// value to the addresses that store it. // 2. Starting from targetAddr, walk backwards up to maxDepth levels: // find all stored pointers that point within [targetAddr-maxOffset, targetAddr]. // 3. Recursively repeat for each found pointer address until we reach a // pointer stored in a static region (module base, [bss], etc.). // 4. Return the resulting chains as []Chain. +// +// Chain offsets are stored in application order (base -> final), i.e. the order +// ResolveChain applies them, matching the Cheat Engine "[[base+o0]+o1]+..." +// convention. package pointer import ( "encoding/binary" "fmt" - "memodroid/internal/driver" + "memdroid/internal/driver" ) const ( @@ -29,13 +34,16 @@ const ( ) // Chain represents one resolved pointer path. -// Offsets[0] is applied to BaseAddr to get the first pointer, -// Offsets[1] to dereference that, etc., until FinalAddr is reached. +// +// The base pointer is stored at moduleBase(BaseLabel)+BaseOffset. Offsets are +// applied in order: read the pointer at the current address, add the offset, +// repeat, until FinalAddr is reached. type Chain struct { - BaseAddr uintptr // address in a static region (module/stack base) - BaseLabel string // name of the region (e.g. "libil2cpp.so") - Offsets []int64 // signed offsets at each level - FinalAddr uintptr // the target address this chain resolves to + BaseAddr uintptr // absolute address of the base pointer at scan time (informational) + BaseLabel string // name of the region (e.g. "libil2cpp.so") + BaseOffset uintptr // offset of the base pointer within its module + Offsets []int64 // signed offsets, base -> final order + FinalAddr uintptr // the target address this chain resolves to } // ScanResult holds all chains found for a target address. @@ -58,44 +66,52 @@ func Scan(drv driver.Driver, pid int, targetAddr uintptr, maxDepth int, maxOffse return nil, fmt.Errorf("read maps: %w", err) } - // Build a map: stored_value -> []address_where_stored - // Only store values that point somewhere inside our mapped regions. - addrSet := buildAddrSet(regions) - ptrMap, err := buildPtrMap(drv, pid, regions, addrSet) + // Map: pointed-to value -> addresses that store it (only values inside our maps). + ptrMap, err := buildPtrMap(drv, pid, regions) if err != nil { return nil, fmt.Errorf("build pointer map: %w", err) } result := &ScanResult{Target: targetAddr} + onPath := map[uintptr]bool{targetAddr: true} + var walk func(addr uintptr, depth int, offsets []int64) walk = func(addr uintptr, depth int, offsets []int64) { if len(result.Chains) >= maxResults { return } - // Find all pointers that point to [addr-maxOffset, addr] + // Find all pointers that point to [addr-maxOffset, addr]. for delta := uintptr(0); delta <= maxOffset; delta += pointerSize { - target := addr - delta - sources, ok := ptrMap[target] + sources, ok := ptrMap[addr-delta] if !ok { continue } - offset := int64(delta) - newOffsets := make([]int64, len(offsets)+1) - copy(newOffsets, offsets) - newOffsets[len(offsets)] = offset + // Discovery order accumulates target-side first; prepend so Offsets + // stay in base -> final application order. + newOffsets := make([]int64, 0, len(offsets)+1) + newOffsets = append(newOffsets, int64(delta)) + newOffsets = append(newOffsets, offsets...) for _, src := range sources { - if depth+1 < maxDepth { - walk(src, depth+1, newOffsets) + if onPath[src] { + continue // cycle } - // Check if src lives in a static region. - if label, ok := staticRegion(src, regions); ok { + if label, base, ok := staticRegion(src, regions); ok { result.Chains = append(result.Chains, Chain{ - BaseAddr: src, - BaseLabel: label, - Offsets: newOffsets, - FinalAddr: targetAddr, + BaseAddr: src, + BaseLabel: label, + BaseOffset: src - base, + Offsets: newOffsets, + FinalAddr: targetAddr, }) + if len(result.Chains) >= maxResults { + return + } + } + if depth+1 < maxDepth { + onPath[src] = true + walk(src, depth+1, newOffsets) + delete(onPath, src) } } } @@ -104,19 +120,10 @@ func Scan(drv driver.Driver, pid int, targetAddr uintptr, maxDepth int, maxOffse return result, nil } -// buildAddrSet returns a set of all addresses within mapped regions. -func buildAddrSet(regions []driver.Region) map[uintptr]struct{} { - s := make(map[uintptr]struct{}) - for _, r := range regions { - s[r.Start] = struct{}{} - } - return s -} - // buildPtrMap scans all rw regions and maps each pointer-valued address to the -// locations that store it. -func buildPtrMap(drv driver.Driver, pid int, regions []driver.Region, addrSet map[uintptr]struct{}) (map[uintptr][]uintptr, error) { - // Determine the full mapped range so we can validate pointer values quickly. +// locations that store it. Each region is read in a single bulk transfer so no +// pointer is missed at a chunk boundary. +func buildPtrMap(drv driver.Driver, pid int, regions []driver.Region) (map[uintptr][]uintptr, error) { var mapMin, mapMax uintptr for i, r := range regions { if i == 0 || r.Start < mapMin { @@ -128,46 +135,56 @@ func buildPtrMap(drv driver.Driver, pid int, regions []driver.Region, addrSet ma } ptrMap := make(map[uintptr][]uintptr) - const chunkSize = 4096 - for _, r := range regions { size := int(r.End - r.Start) - for offset := 0; offset < size-pointerSize+1; offset += chunkSize { - end := offset + chunkSize - if end > size { - end = size - } - chunk, err := drv.ReadBytes(pid, r.Start+uintptr(offset), end-offset) - if err != nil { - continue - } - for i := 0; i+pointerSize <= len(chunk); i += pointerSize { - val := uintptr(binary.LittleEndian.Uint64(chunk[i:])) - if val >= mapMin && val < mapMax { - srcAddr := r.Start + uintptr(offset+i) - ptrMap[val] = append(ptrMap[val], srcAddr) - } + if size < pointerSize { + continue + } + buf, err := drv.ReadRegion(pid, r.Start, size) + if err != nil { + continue + } + for i := 0; i+pointerSize <= len(buf); i += pointerSize { + val := uintptr(binary.LittleEndian.Uint64(buf[i:])) + if val >= mapMin && val < mapMax { + ptrMap[val] = append(ptrMap[val], r.Start+uintptr(i)) } } } return ptrMap, nil } -// staticRegion returns the region name if addr lies in a non-anonymous region -// (i.e. a mapped file like a .so), which is considered a stable base. -func staticRegion(addr uintptr, regions []driver.Region) (string, bool) { +// staticRegion returns the region name and module base if addr lies in a +// non-anonymous region (a mapped file like a .so), which is a stable base. +func staticRegion(addr uintptr, regions []driver.Region) (string, uintptr, bool) { + for _, r := range regions { + if addr >= r.Start && addr < r.End && isStaticName(r.Name) { + return r.Name, moduleBase(regions, r.Name), true + } + } + return "", 0, false +} + +func isStaticName(name string) bool { + return name != "" && name != "[heap]" && name != "[stack]" && name != "[anon]" +} + +// moduleBase returns the lowest start address of any region named name. +func moduleBase(regions []driver.Region, name string) uintptr { + var base uintptr + found := false for _, r := range regions { - if addr >= r.Start && addr < r.End && r.Name != "" && - r.Name != "[heap]" && r.Name != "[stack]" && r.Name != "[anon]" { - return r.Name, true + if r.Name == name && (!found || r.Start < base) { + base = r.Start + found = true } } - return "", false + return base } // FormatChain returns a human-readable pointer path string. func FormatChain(c Chain) string { - s := fmt.Sprintf("[%s+0x%x]", c.BaseLabel, c.BaseAddr) + s := fmt.Sprintf("[%s+0x%x]", c.BaseLabel, c.BaseOffset) for _, off := range c.Offsets { if off >= 0 { s += fmt.Sprintf("+0x%x", off) diff --git a/internal/memory/pointer/pointer_test.go b/internal/memory/pointer/pointer_test.go new file mode 100644 index 0000000..3c5e9af --- /dev/null +++ b/internal/memory/pointer/pointer_test.go @@ -0,0 +1,130 @@ +package pointer + +import ( + "encoding/binary" + "fmt" + "testing" + + "memdroid/internal/driver" +) + +// fakeDriver is an in-memory driver used to exercise Scan/ResolveChain without +// a real device. It only implements the methods the pointer package uses. +type fakeDriver struct { + regions []driver.Region + mem map[uintptr][]byte // region.Start -> full region bytes +} + +func newFakeDriver(regions []driver.Region) *fakeDriver { + f := &fakeDriver{regions: regions, mem: make(map[uintptr][]byte)} + for _, r := range regions { + f.mem[r.Start] = make([]byte, r.End-r.Start) + } + return f +} + +// putPtr writes a little-endian 64-bit pointer value at addr. +func (f *fakeDriver) putPtr(addr, val uintptr) { + for _, r := range f.regions { + if addr >= r.Start && addr < r.End { + binary.LittleEndian.PutUint64(f.mem[r.Start][addr-r.Start:], uint64(val)) + return + } + } + panic(fmt.Sprintf("putPtr: addr 0x%x not in any region", addr)) +} + +func (f *fakeDriver) ReadMaps(int) ([]driver.Region, error) { return f.regions, nil } + +func (f *fakeDriver) ReadMapsFiltered(_ int, _ driver.RegionFilter, _, _ uintptr) ([]driver.Region, error) { + return f.regions, nil +} + +func (f *fakeDriver) ReadRegion(_ int, addr uintptr, size int) ([]byte, error) { + for _, r := range f.regions { + if addr >= r.Start && addr < r.End { + buf := f.mem[r.Start] + off := int(addr - r.Start) + end := off + size + if end > len(buf) { + end = len(buf) + } + return buf[off:end], nil + } + } + return nil, fmt.Errorf("unmapped 0x%x", addr) +} + +func (f *fakeDriver) Peek(pid int, addr uintptr, size int) ([]byte, error) { + return f.ReadRegion(pid, addr, size) +} + +// Unused interface methods. +func (f *fakeDriver) ListDevices() ([]string, error) { return nil, nil } +func (f *fakeDriver) SelectDevice(string) error { return nil } +func (f *fakeDriver) DeviceSerial() string { return "" } +func (f *fakeDriver) ListProcesses() ([]driver.ProcessInfo, error) { return nil, nil } +func (f *fakeDriver) Attach(int) error { return nil } +func (f *fakeDriver) Detach(int) {} +func (f *fakeDriver) Stop(int) error { return nil } +func (f *fakeDriver) Continue(int) error { return nil } +func (f *fakeDriver) Poke(int, uintptr, []byte) error { return nil } +func (f *fakeDriver) ReadBytes(pid int, addr uintptr, n int) ([]byte, error) { + return f.ReadRegion(pid, addr, n) +} + +// TestScanResolveRoundTrip builds a two-level pointer chain and verifies that +// Scan discovers it and ResolveChain follows it back to the target. +func TestScanResolveRoundTrip(t *testing.T) { + const ( + moduleStart = uintptr(0x1000) + heapStart = uintptr(0x40000000) + target = uintptr(0x40001100) // within DefaultMaxOffset of p2 + + base = moduleStart + 0x10 // 0x1010, base pointer slot in module + p1 = heapStart // 0x40000000 + p2 = uintptr(0x40001000) + ) + + f := newFakeDriver([]driver.Region{ + {Start: moduleStart, End: moduleStart + 0x1000, Name: "libtest.so"}, + {Start: heapStart, End: heapStart + 0x10000, Name: "[heap]"}, + }) + // base -> p1 ; (p1+0x8) -> p2 ; p2 + 0x1000 == target + f.putPtr(base, p1) + f.putPtr(p1+0x8, p2) + + res, err := Scan(f, 1, target, DefaultMaxDepth, DefaultMaxOffset) + if err != nil { + t.Fatalf("Scan: %v", err) + } + if len(res.Chains) == 0 { + t.Fatalf("Scan found no chains") + } + + // Every discovered chain must resolve back to the target. + foundModuleChain := false + for _, c := range res.Chains { + if c.BaseLabel == "libtest.so" { + foundModuleChain = true + } + got, err := ResolveChain(f, 1, c) + if err != nil { + t.Fatalf("ResolveChain(%s): %v", FormatChain(c), err) + } + if got != target { + t.Errorf("chain %s resolved to 0x%x, want 0x%x", FormatChain(c), got, target) + } + } + if !foundModuleChain { + t.Errorf("expected a chain based in libtest.so") + } +} + +func TestResolveChainUnknownModule(t *testing.T) { + f := newFakeDriver([]driver.Region{{Start: 0x1000, End: 0x2000, Name: "libtest.so"}}) + _, err := ResolveChain(f, 1, Chain{BaseLabel: "missing.so", Offsets: []int64{0x8}}) + if err == nil { + t.Errorf("expected error for unknown module") + } +} diff --git a/internal/memory/pointer/resolve.go b/internal/memory/pointer/resolve.go index cd41433..c8957ab 100644 --- a/internal/memory/pointer/resolve.go +++ b/internal/memory/pointer/resolve.go @@ -4,53 +4,32 @@ import ( "encoding/binary" "fmt" - "memodroid/internal/driver" + "memdroid/internal/driver" ) // ResolveChain resolves a saved pointer chain against the current process state. -// It finds the module base by matching Chain.BaseLabel in the process's memory maps, -// then walks the pointer chain (reading each pointer and adding the next offset) -// to arrive at the final address. +// It finds the module base by matching Chain.BaseLabel in the process's memory +// maps, rebases the base pointer to moduleBase+BaseOffset, then walks the chain +// (read pointer, add offset) in base->final order to reach the final address. func ResolveChain(drv driver.Driver, pid int, chain Chain) (uintptr, error) { regions, err := drv.ReadMaps(pid) if err != nil { return 0, fmt.Errorf("read maps: %w", err) } - // Find the module base address by label - var baseAddr uintptr - found := false - for _, r := range regions { - if r.Name == chain.BaseLabel { - baseAddr = r.Start - found = true - break - } - } - if !found { + base := moduleBase(regions, chain.BaseLabel) + if base == 0 { return 0, fmt.Errorf("module %q not found in memory maps", chain.BaseLabel) } - // The chain's BaseAddr was an absolute address in the original run. - // We compute the offset from the original module base by looking at the - // relative position. However, the Chain stores the actual address where - // the first pointer was stored. We need to rebase it. - // The stored BaseAddr is relative to the original module load. - // For simplicity, use the stored base offset from the module start: - // since we stored an absolute BaseAddr at scan time, we need the offset - // within the module. We'll use the chain offsets directly starting from - // the new module base. - - // Walk the chain: start at baseAddr, read pointer, add offset, repeat - current := baseAddr + current := base + chain.BaseOffset for i, off := range chain.Offsets { - // Read pointer at current address data, err := drv.Peek(pid, current, pointerSize) if err != nil { return 0, fmt.Errorf("read pointer at 0x%x (step %d): %w", current, i, err) } - ptr := uintptr(binary.LittleEndian.Uint64(data)) - current = ptr + uintptr(off) + ptr := int64(binary.LittleEndian.Uint64(data)) + current = uintptr(ptr + off) } return current, nil diff --git a/internal/memory/search/filter.go b/internal/memory/search/filter.go index d8e5e7d..8762704 100644 --- a/internal/memory/search/filter.go +++ b/internal/memory/search/filter.go @@ -5,7 +5,7 @@ import ( "sort" "sync" - "memodroid/internal/driver" + "memdroid/internal/driver" ) type FilterMode int @@ -18,9 +18,16 @@ const ( FilterValue ) +func (m FilterMode) valid() bool { + return m >= FilterChanged && m <= FilterValue +} + // Filter narrows the candidate list by re-reading current values and applying mode. // For FilterValue, pass the target bytes; otherwise target may be nil. func (s *Session) Filter(mode FilterMode, target []byte) error { + if !mode.valid() { + return fmt.Errorf("invalid filter mode %d", mode) + } if !s.HasCandidates() { return fmt.Errorf("no active search session") } @@ -34,61 +41,54 @@ func (s *Session) Filter(mode FilterMode, target []byte) error { if err != nil { return fmt.Errorf("filter: read maps: %w", err) } + sort.Slice(regions, func(i, j int) bool { return regions[i].Start < regions[j].Start }) - type regionData struct { - start uintptr - buf []byte + // regionFor binary-searches the sorted region list for the region containing addr. + regionFor := func(addr uintptr) int { + i := sort.Search(len(regions), func(k int) bool { return regions[k].Start > addr }) - 1 + if i >= 0 && addr < regions[i].End { + return i + } + return -1 } - // Determine which regions contain candidates so we only read those. + // Gather candidates in address order and record which regions to read. type candEntry struct { - addr uintptr - prev []byte - } - entries := make([]candEntry, 0, len(s.Candidates)) - for addr, prev := range s.Candidates { - entries = append(entries, candEntry{addr, prev}) - } - sort.Slice(entries, func(i, j int) bool { return entries[i].addr < entries[j].addr }) - - regionFor := func(addr uintptr) (int, *driver.Region) { - for i := range regions { - if addr >= regions[i].Start && addr < regions[i].End { - return i, ®ions[i] - } - } - return -1, nil + addr uintptr + prev []byte + regIdx int } - - // Identify needed regions. + snap := s.Snapshot() + entries := make([]candEntry, 0, len(snap)) needed := make(map[int]struct{}) - for _, e := range entries { - if idx, _ := regionFor(e.addr); idx >= 0 { + for addr, prev := range snap { + idx := regionFor(addr) + entries = append(entries, candEntry{addr: addr, prev: prev, regIdx: idx}) + if idx >= 0 { needed[idx] = struct{}{} } } + sort.Slice(entries, func(i, j int) bool { return entries[i].addr < entries[j].addr }) // Pre-load needed regions in parallel. - cache := make(map[int]*regionData) + cache := make(map[int][]byte) var cacheMu sync.Mutex var wg sync.WaitGroup sem := make(chan struct{}, scanWorkers) - for idx := range needed { idx := idx - r := ®ions[idx] wg.Add(1) sem <- struct{}{} go func() { defer wg.Done() defer func() { <-sem }() + r := regions[idx] buf, readErr := s.Driver.ReadRegion(s.PID, r.Start, int(r.End-r.Start)) - cacheMu.Lock() if readErr != nil { - cache[idx] = nil - } else { - cache[idx] = ®ionData{start: r.Start, buf: buf} + return } + cacheMu.Lock() + cache[idx] = buf cacheMu.Unlock() }() } @@ -101,44 +101,53 @@ func (s *Session) Filter(mode FilterMode, target []byte) error { sz = len(e.prev) } - idx, _ := regionFor(e.addr) - var cur []byte - if idx >= 0 { - if d := cache[idx]; d != nil { - off := int(e.addr - d.start) - if off >= 0 && off+sz <= len(d.buf) { - cur = d.buf[off : off+sz] - } - } - } + cur := sliceFromCache(cache, regions, e.regIdx, e.addr, sz) if cur == nil { - cur, err = s.Driver.Peek(s.PID, e.addr, sz) - if err != nil { + c, readErr := s.Driver.Peek(s.PID, e.addr, sz) + if readErr != nil { continue } + cur = c } - var keep bool - switch mode { - case FilterChanged: - keep = !EqualBytes(cur, e.prev) - case FilterUnchanged: - keep = EqualBytes(cur, e.prev) - case FilterIncreased: - keep = CompareValues(cur, e.prev, s.ValueType) > 0 - case FilterDecreased: - keep = CompareValues(cur, e.prev, s.ValueType) < 0 - case FilterValue: - keep = EqualBytes(cur, target) - } - - if keep { - cp := make([]byte, sz) - copy(cp, cur) - next[e.addr] = cp + if filterKeep(mode, cur, e.prev, target, s.ValueType) { + next[e.addr] = cloneBytes(cur) } } - s.Candidates = next + s.replace(next) return nil } + +// sliceFromCache returns the sz bytes at addr from the cached region buffer, or +// nil if the region was not cached or the slice would fall out of bounds. +func sliceFromCache(cache map[int][]byte, regions []driver.Region, idx int, addr uintptr, sz int) []byte { + if idx < 0 { + return nil + } + buf, ok := cache[idx] + if !ok { + return nil + } + off := int(addr - regions[idx].Start) + if off < 0 || off+sz > len(buf) { + return nil + } + return buf[off : off+sz] +} + +func filterKeep(mode FilterMode, cur, prev, target []byte, vt ValueType) bool { + switch mode { + case FilterChanged: + return !EqualBytes(cur, prev) + case FilterUnchanged: + return EqualBytes(cur, prev) + case FilterIncreased: + return CompareValues(cur, prev, vt) > 0 + case FilterDecreased: + return CompareValues(cur, prev, vt) < 0 + case FilterValue: + return EqualBytes(cur, target) + } + return false +} diff --git a/internal/memory/search/pattern.go b/internal/memory/search/pattern.go index 80d14cf..25db46a 100644 --- a/internal/memory/search/pattern.go +++ b/internal/memory/search/pattern.go @@ -5,13 +5,13 @@ import ( "strconv" "strings" - "memodroid/internal/driver" + "memdroid/internal/driver" ) const ( patternMaxRegionBytes = 256 * 1024 * 1024 patternChunkSize = 4096 - patternMaxResults = 200 + PatternMaxResults = 200 ) // PatternByte represents one byte in a search pattern; Wildcard=true matches any byte. @@ -80,7 +80,7 @@ func SearchPattern(drv driver.Driver, pid int, pattern []PatternByte) ([]uintptr if matchPattern(buf[i:], pattern) { addr := r.Start + uintptr(offset) - uintptr(len(prev)) + uintptr(i) results = append(results, addr) - if len(results) >= patternMaxResults { + if len(results) >= PatternMaxResults { return results, nil } } diff --git a/internal/memory/search/search.go b/internal/memory/search/search.go index 3aca4bc..ec632b0 100644 --- a/internal/memory/search/search.go +++ b/internal/memory/search/search.go @@ -3,7 +3,7 @@ package search import ( "sync" - "memodroid/internal/driver" + "memdroid/internal/driver" ) // scanWorkers limits concurrent ADB calls during parallel memory reads. @@ -21,65 +21,43 @@ func (s *Session) SearchFiltered(target []byte, filter driver.RegionFilter, cust return err } - found := make(map[uintptr][]byte) - + var found map[uintptr][]byte if s.ValueType == TypeBytes { - searchBytesInRegions(s.Driver, s.PID, regions, target, found) + tlen := len(target) + found = scanRegions(s.Driver, s.PID, regions, tlen, func(buf []byte, base uintptr, out map[uintptr][]byte) { + for i := 0; i+tlen <= len(buf); i++ { + if EqualBytes(buf[i:i+tlen], target) { + out[base+uintptr(i)] = cloneBytes(buf[i : i+tlen]) + } + } + }) } else { size := s.ValueType.Size() - var mu sync.Mutex - var wg sync.WaitGroup - sem := make(chan struct{}, scanWorkers) - - for _, r := range regions { - regionSize := int(r.End - r.Start) - if regionSize <= 0 { - continue - } - wg.Add(1) - sem <- struct{}{} - go func(r driver.Region, regionSize int) { - defer wg.Done() - defer func() { <-sem }() - - buf, err := s.Driver.ReadRegion(s.PID, r.Start, regionSize) - if err != nil { - return - } - local := make(map[uintptr][]byte) - for i := 0; i+size <= len(buf); i += size { - if EqualBytes(buf[i:i+size], target) { - cp := make([]byte, size) - copy(cp, buf[i:i+size]) - local[r.Start+uintptr(i)] = cp - } + found = scanRegions(s.Driver, s.PID, regions, size, func(buf []byte, base uintptr, out map[uintptr][]byte) { + for i := 0; i+size <= len(buf); i += size { + if EqualBytes(buf[i:i+size], target) { + out[base+uintptr(i)] = cloneBytes(buf[i : i+size]) } - if len(local) > 0 { - mu.Lock() - for addr, val := range local { - found[addr] = val - } - mu.Unlock() - } - }(r, regionSize) - } - wg.Wait() + } + }) } - s.Candidates = found - s.active = true + s.replace(found) return nil } -func searchBytesInRegions(drv driver.Driver, pid int, regions []driver.Region, target []byte, found map[uintptr][]byte) { - tlen := len(target) +// scanRegions reads each region once (in parallel, bounded by scanWorkers) and +// invokes scan on the resulting buffer to collect matches. Regions smaller than +// minSize are skipped. It is the shared engine for value and byte-sequence scans. +func scanRegions(drv driver.Driver, pid int, regions []driver.Region, minSize int, scan func(buf []byte, base uintptr, out map[uintptr][]byte)) map[uintptr][]byte { + found := make(map[uintptr][]byte) var mu sync.Mutex var wg sync.WaitGroup sem := make(chan struct{}, scanWorkers) for _, r := range regions { size := int(r.End - r.Start) - if size <= 0 || size < tlen { + if size <= 0 || size < minSize { continue } wg.Add(1) @@ -93,13 +71,7 @@ func searchBytesInRegions(drv driver.Driver, pid int, regions []driver.Region, t return } local := make(map[uintptr][]byte) - for i := 0; i <= len(buf)-tlen; i++ { - if EqualBytes(buf[i:i+tlen], target) { - cp := make([]byte, tlen) - copy(cp, target) - local[r.Start+uintptr(i)] = cp - } - } + scan(buf, r.Start, local) if len(local) > 0 { mu.Lock() for addr, val := range local { @@ -110,4 +82,11 @@ func searchBytesInRegions(drv driver.Driver, pid int, regions []driver.Region, t }(r, size) } wg.Wait() + return found +} + +func cloneBytes(b []byte) []byte { + cp := make([]byte, len(b)) + copy(cp, b) + return cp } diff --git a/internal/memory/search/session.go b/internal/memory/search/session.go index 62376b9..2fde3f2 100644 --- a/internal/memory/search/session.go +++ b/internal/memory/search/session.go @@ -1,12 +1,22 @@ package search -import "memodroid/internal/driver" +import ( + "sync" + + "memdroid/internal/driver" +) // Session holds the current search state for a single attached process. +// +// Candidates is guarded by mu. Long-running scans (Search/Filter) do their ADB +// I/O without holding the lock and only take it to swap in the finished result, +// so concurrent CLI/HTTP access never observes a half-built candidate map. type Session struct { - PID int - ValueType ValueType - Driver driver.Driver + PID int + ValueType ValueType + Driver driver.Driver + + mu sync.Mutex Candidates map[uintptr][]byte active bool } @@ -16,33 +26,53 @@ func NewSession(pid int, vt ValueType, drv driver.Driver) *Session { } func (s *Session) HasCandidates() bool { + s.mu.Lock() + defer s.mu.Unlock() return s.active && len(s.Candidates) > 0 } func (s *Session) CandidateCount() int { + s.mu.Lock() + defer s.mu.Unlock() return len(s.Candidates) } func (s *Session) Reset() { + s.mu.Lock() + defer s.mu.Unlock() s.Candidates = nil s.active = false } -// SetActive marks the session as having been populated (used after loading from disk). -func (s *Session) SetActive() { +// SetCandidates replaces the candidate map with c and marks the session active. +// Used after loading from disk or a pattern/string scan. +func (s *Session) SetCandidates(c map[uintptr][]byte) { + s.replace(c) +} + +// replace swaps in a freshly built candidate map and marks the session active. +func (s *Session) replace(found map[uintptr][]byte) { + s.mu.Lock() + defer s.mu.Unlock() + s.Candidates = found s.active = true } -// firstCandidateLen returns the byte length of an arbitrary candidate (for TypeBytes). +// firstCandidateLen returns the byte length of an arbitrary candidate, or 1 if +// the session is empty. Used for TypeBytes where the width is data-defined. func (s *Session) firstCandidateLen() int { + s.mu.Lock() + defer s.mu.Unlock() for _, v := range s.Candidates { return len(v) } return 1 } -// Snapshot returns a copy of the current address -> value map. +// Snapshot returns a deep copy of the current address -> value map. func (s *Session) Snapshot() map[uintptr][]byte { + s.mu.Lock() + defer s.mu.Unlock() out := make(map[uintptr][]byte, len(s.Candidates)) for addr, val := range s.Candidates { cp := make([]byte, len(val)) diff --git a/internal/memory/search/string.go b/internal/memory/search/string.go index 67a9335..325f76a 100644 --- a/internal/memory/search/string.go +++ b/internal/memory/search/string.go @@ -4,7 +4,7 @@ import ( "fmt" "unicode/utf16" - "memodroid/internal/driver" + "memdroid/internal/driver" ) // SearchStringUTF8 scans memory for an exact UTF-8 string match. @@ -20,13 +20,21 @@ func SearchStringUTF16(drv driver.Driver, pid int, s string) ([]uintptr, error) if s == "" { return nil, fmt.Errorf("empty string") } + return SearchPattern(drv, pid, bytesToPattern(StringBytes(s, true))) +} + +// StringBytes encodes s as UTF-8 (utf16le=false) or UTF-16LE (utf16le=true). +func StringBytes(s string, utf16le bool) []byte { + if !utf16le { + return []byte(s) + } encoded := utf16.Encode([]rune(s)) buf := make([]byte, len(encoded)*2) for i, u := range encoded { buf[i*2] = byte(u) buf[i*2+1] = byte(u >> 8) } - return SearchPattern(drv, pid, bytesToPattern(buf)) + return buf } func bytesToPattern(b []byte) []PatternByte { diff --git a/internal/memory/search/types.go b/internal/memory/search/types.go index 6c1aca5..26fb584 100644 --- a/internal/memory/search/types.go +++ b/internal/memory/search/types.go @@ -148,56 +148,41 @@ func FormatValue(b []byte, t ValueType) string { return "?" } -// CompareValues returns -1, 0, or 1. Not defined for TypeBytes. +// CompareValues returns -1, 0, or 1. It returns 0 for TypeBytes and for +// slices shorter than the type's fixed width (guarding against panics on +// truncated reads). func CompareValues(a, b []byte, t ValueType) int { + size := t.Size() + if size == 0 || len(a) < size || len(b) < size { + return 0 + } switch t { case TypeInt32: - return cmpInt(int64(int32(binary.LittleEndian.Uint32(a))), - int64(int32(binary.LittleEndian.Uint32(b)))) + return cmp(int32(binary.LittleEndian.Uint32(a)), int32(binary.LittleEndian.Uint32(b))) case TypeInt64: - return cmpInt(int64(binary.LittleEndian.Uint64(a)), - int64(binary.LittleEndian.Uint64(b))) + return cmp(int64(binary.LittleEndian.Uint64(a)), int64(binary.LittleEndian.Uint64(b))) case TypeFloat32: - return cmpFloat(float64(math.Float32frombits(binary.LittleEndian.Uint32(a))), - float64(math.Float32frombits(binary.LittleEndian.Uint32(b)))) + return cmp(math.Float32frombits(binary.LittleEndian.Uint32(a)), math.Float32frombits(binary.LittleEndian.Uint32(b))) case TypeFloat64: - return cmpFloat(math.Float64frombits(binary.LittleEndian.Uint64(a)), - math.Float64frombits(binary.LittleEndian.Uint64(b))) + return cmp(math.Float64frombits(binary.LittleEndian.Uint64(a)), math.Float64frombits(binary.LittleEndian.Uint64(b))) case TypeUint32: - return cmpUint(uint64(binary.LittleEndian.Uint32(a)), - uint64(binary.LittleEndian.Uint32(b))) + return cmp(binary.LittleEndian.Uint32(a), binary.LittleEndian.Uint32(b)) case TypeUint64: - return cmpUint(binary.LittleEndian.Uint64(a), - binary.LittleEndian.Uint64(b)) - } - return 0 -} - -func cmpInt(a, b int64) int { - if a < b { - return -1 - } else if a > b { - return 1 + return cmp(binary.LittleEndian.Uint64(a), binary.LittleEndian.Uint64(b)) } return 0 } -func cmpUint(a, b uint64) int { - if a < b { +// cmp orders two ordered values, returning -1, 0, or 1. +func cmp[T int32 | int64 | uint32 | uint64 | float32 | float64](a, b T) int { + switch { + case a < b: return -1 - } else if a > b { - return 1 - } - return 0 -} - -func cmpFloat(a, b float64) int { - if a < b { - return -1 - } else if a > b { + case a > b: return 1 + default: + return 0 } - return 0 } // EqualBytes reports whether a and b contain identical bytes. diff --git a/internal/memory/search/types_test.go b/internal/memory/search/types_test.go new file mode 100644 index 0000000..a93eb75 --- /dev/null +++ b/internal/memory/search/types_test.go @@ -0,0 +1,74 @@ +package search + +import "testing" + +func TestParseFormatRoundTrip(t *testing.T) { + cases := []struct { + vt ValueType + in string + out string + }{ + {TypeInt32, "-5", "-5"}, + {TypeInt64, "9000000000", "9000000000"}, + {TypeUint32, "4294967295", "4294967295"}, + {TypeUint64, "18446744073709551615", "18446744073709551615"}, + {TypeFloat32, "1.5", "1.5"}, + {TypeFloat64, "3.25", "3.25"}, + {TypeBytes, "FF00AB", "ff00ab"}, + } + for _, c := range cases { + b, err := ParseValue(c.in, c.vt) + if err != nil { + t.Fatalf("ParseValue(%q, %v): %v", c.in, c.vt, err) + } + if got := FormatValue(b, c.vt); got != c.out { + t.Errorf("round-trip %v %q: got %q want %q", c.vt, c.in, got, c.out) + } + } +} + +func TestCompareValues(t *testing.T) { + a, _ := ParseValue("10", TypeInt32) + b, _ := ParseValue("20", TypeInt32) + if CompareValues(a, b, TypeInt32) != -1 { + t.Errorf("10 < 20 should be -1") + } + if CompareValues(b, a, TypeInt32) != 1 { + t.Errorf("20 > 10 should be 1") + } + if CompareValues(a, a, TypeInt32) != 0 { + t.Errorf("10 == 10 should be 0") + } + + neg, _ := ParseValue("-1", TypeInt32) + if CompareValues(neg, a, TypeInt32) != -1 { + t.Errorf("signed compare: -1 < 10") + } + big, _ := ParseValue("4294967295", TypeUint32) // 0xFFFFFFFF + if CompareValues(big, a, TypeUint32) != 1 { + t.Errorf("unsigned compare: max > 10") + } +} + +func TestCompareValuesGuards(t *testing.T) { + // Short slices must not panic and must return 0. + if CompareValues([]byte{1}, []byte{2}, TypeInt32) != 0 { + t.Errorf("short slice should compare as 0") + } + // TypeBytes is not ordered → 0. + if CompareValues([]byte{1, 2, 3}, []byte{4, 5, 6}, TypeBytes) != 0 { + t.Errorf("TypeBytes should compare as 0") + } +} + +func TestParseValueTypeAndFilterMode(t *testing.T) { + if vt, err := ParseValueType("uint64"); err != nil || vt != TypeUint64 { + t.Errorf("ParseValueType(uint64) = %v, %v", vt, err) + } + if _, err := ParseValueType("nope"); err == nil { + t.Errorf("expected error for unknown type") + } + if m, err := ParseFilterMode("increased"); err != nil || m != FilterIncreased { + t.Errorf("ParseFilterMode(increased) = %v, %v", m, err) + } +} diff --git a/internal/memory/store/bookmark.go b/internal/memory/store/bookmark.go index a9dd9a0..e4f3971 100644 --- a/internal/memory/store/bookmark.go +++ b/internal/memory/store/bookmark.go @@ -3,8 +3,8 @@ package store import ( "fmt" - "memodroid/internal/driver" - "memodroid/internal/memory/search" + "memdroid/internal/driver" + "memdroid/internal/memory/search" ) type Bookmark struct { diff --git a/internal/memory/store/cheatengine.go b/internal/memory/store/cheatengine.go index cf74fda..ba85730 100644 --- a/internal/memory/store/cheatengine.go +++ b/internal/memory/store/cheatengine.go @@ -7,7 +7,7 @@ import ( "strconv" "strings" - "memodroid/internal/memory/search" + "memdroid/internal/memory/search" ) // ctFile represents the top-level CheatEngine .CT XML structure. @@ -67,11 +67,11 @@ func parseCTAddress(s string) (uintptr, error) { // Remove quotes if present s = strings.Trim(s, "\"") - // Handle module+offset like "game.exe+1A2B" — just use the offset + // Handle module+offset like "game.exe+1A2B" — keep only the offset. + // (A module-relative address can't be represented as an absolute bookmark + // without rebasing; this is a known lossy import.) if idx := strings.LastIndex(s, "+"); idx > 0 { - // Check if left side looks like a module name (contains non-hex chars) - left := s[:idx] - if strings.ContainsAny(left, "ghijklmnopqrstuvwxyzGHIJKLMNOPQRSTUVWXYZ._") { + if !isHexString(s[:idx]) { s = s[idx+1:] } } @@ -87,6 +87,21 @@ func parseCTAddress(s string) (uintptr, error) { return uintptr(v), nil } +// isHexString reports whether s is non-empty and consists only of hex digits. +func isHexString(s string) bool { + if s == "" { + return false + } + for _, c := range s { + switch { + case c >= '0' && c <= '9', c >= 'a' && c <= 'f', c >= 'A' && c <= 'F': + default: + return false + } + } + return true +} + // ctVariableType maps CE variable type strings to our ValueType. func ctVariableType(s string) search.ValueType { switch strings.ToLower(strings.TrimSpace(s)) { diff --git a/internal/memory/store/cheatengine_test.go b/internal/memory/store/cheatengine_test.go new file mode 100644 index 0000000..5af5f1b --- /dev/null +++ b/internal/memory/store/cheatengine_test.go @@ -0,0 +1,53 @@ +package store + +import "testing" + +func TestParseCTAddress(t *testing.T) { + cases := []struct { + in string + want uintptr + }{ + {"0x1A2B", 0x1A2B}, + {"1A2B", 0x1A2B}, + {"\"deadBEEF\"", 0xDEADBEEF}, + {"game.exe+1000", 0x1000}, // named module → keep offset + {"libil2cpp.so+2A", 0x2A}, // named module → keep offset + } + for _, c := range cases { + got, err := parseCTAddress(c.in) + if err != nil { + t.Fatalf("parseCTAddress(%q): %v", c.in, err) + } + if got != c.want { + t.Errorf("parseCTAddress(%q) = 0x%x, want 0x%x", c.in, got, c.want) + } + } +} + +func TestParseCTAddressHexModuleKept(t *testing.T) { + // A purely-hex left side is NOT a module name, so the whole "abcd+10" + // parses via the offset branch (left "abcd" is hex → keep only "10"). + // The important property: a hex-looking module name is not misclassified + // away, and named modules are detected. + if !isHexString("abcd") { + t.Errorf("abcd should be recognised as hex") + } + if isHexString("game.exe") { + t.Errorf("game.exe should not be hex") + } + if isHexString("") { + t.Errorf("empty string is not hex") + } +} + +func TestCTVariableType(t *testing.T) { + if ctVariableType("4 Bytes").String() != "int32" { + t.Errorf("4 Bytes should map to int32") + } + if ctVariableType("Float").String() != "float32" { + t.Errorf("Float should map to float32") + } + if ctVariableType("Double").String() != "float64" { + t.Errorf("Double should map to float64") + } +} diff --git a/internal/memory/store/save.go b/internal/memory/store/save.go index 5f41bbf..7f586fb 100644 --- a/internal/memory/store/save.go +++ b/internal/memory/store/save.go @@ -5,10 +5,13 @@ import ( "fmt" "os" - "memodroid/internal/memory/search" + "memdroid/internal/memory/search" ) -const stateFilePerms = 0o600 +const ( + stateFilePerms = 0o600 + stateVersion = 1 +) type savedBookmark struct { Addr uint64 `json:"addr"` @@ -23,13 +26,14 @@ type savedSession struct { } type saveFile struct { + Version int `json:"version"` Bookmarks []savedBookmark `json:"bookmarks"` Session *savedSession `json:"session,omitempty"` } // SaveState serializes bookmarks and the active search session to a JSON file. func SaveState(path string, bl *BookmarkList, s *search.Session) error { - sf := saveFile{} + sf := saveFile{Version: stateVersion} for _, b := range bl.Entries { sf.Bookmarks = append(sf.Bookmarks, savedBookmark{ @@ -45,7 +49,7 @@ func SaveState(path string, bl *BookmarkList, s *search.Session) error { ValueType: int(s.ValueType), Candidates: make(map[string][]byte), } - for addr, val := range s.Candidates { + for addr, val := range s.Snapshot() { sc.Candidates[fmt.Sprintf("0x%x", addr)] = val } sf.Session = sc @@ -58,41 +62,46 @@ func SaveState(path string, bl *BookmarkList, s *search.Session) error { return os.WriteFile(path, data, stateFilePerms) } -// LoadState deserializes bookmarks and session from a JSON file. -// The loaded session has a nil Driver; the caller must set it before searching. -func LoadState(path string, bl *BookmarkList, s **search.Session) error { +// LoadState deserializes bookmarks (into bl, replacing its contents) and returns +// the saved search session if present. The returned session has a nil Driver; +// the caller must set it before searching. +func LoadState(path string, bl *BookmarkList) (*search.Session, error) { data, err := os.ReadFile(path) if err != nil { - return err + return nil, err } var sf saveFile if err := json.Unmarshal(data, &sf); err != nil { - return err + return nil, err + } + if sf.Version != 0 && sf.Version != stateVersion { + return nil, fmt.Errorf("unsupported state file version %d (expected %d)", sf.Version, stateVersion) } - bl.Entries = nil + entries := make([]Bookmark, 0, len(sf.Bookmarks)) for _, b := range sf.Bookmarks { - bl.Entries = append(bl.Entries, Bookmark{ + entries = append(entries, Bookmark{ Addr: uintptr(b.Addr), Label: b.Label, VType: search.ValueType(b.VType), }) } + bl.Entries = entries - if sf.Session != nil { - loaded := search.NewSession(sf.Session.PID, search.ValueType(sf.Session.ValueType), nil) - loaded.Candidates = make(map[uintptr][]byte) - for key, val := range sf.Session.Candidates { - var addr uint64 - if _, err := fmt.Sscanf(key, "0x%x", &addr); err != nil { - continue - } - loaded.Candidates[uintptr(addr)] = val - } - loaded.SetActive() - *s = loaded + if sf.Session == nil { + return nil, nil } - return nil + loaded := search.NewSession(sf.Session.PID, search.ValueType(sf.Session.ValueType), nil) + cands := make(map[uintptr][]byte, len(sf.Session.Candidates)) + for key, val := range sf.Session.Candidates { + var addr uint64 + if _, err := fmt.Sscanf(key, "0x%x", &addr); err != nil { + continue + } + cands[uintptr(addr)] = val + } + loaded.SetCandidates(cands) + return loaded, nil } diff --git a/internal/memory/watch/alert.go b/internal/memory/watch/alert.go index 938978a..befc15f 100644 --- a/internal/memory/watch/alert.go +++ b/internal/memory/watch/alert.go @@ -2,11 +2,11 @@ package watch import ( "fmt" - "sync" "time" - "memodroid/internal/driver" - "memodroid/internal/memory/search" + "memdroid/internal/driver" + "memdroid/internal/memory/search" + "memdroid/internal/poller" ) // AlertCondition defines when an alert fires. @@ -43,23 +43,15 @@ type AlertEvent struct { Triggered bool // whether an action was taken } -type alertEntry struct { - cfg AlertConfig - drv driver.Driver - pid int - vt search.ValueType - stop chan struct{} -} - // AlertWatcher manages conditional watches with automatic actions. +// OnAlert is invoked from watcher goroutines and must be safe for concurrent use. type AlertWatcher struct { - mu sync.Mutex - entries map[uintptr]*alertEntry + pool *poller.Pool OnAlert func(AlertEvent) } func NewAlertWatcher() *AlertWatcher { - return &AlertWatcher{entries: make(map[uintptr]*alertEntry)} + return &AlertWatcher{pool: poller.New()} } func ParseAlertCondition(s string) (AlertCondition, error) { @@ -91,102 +83,61 @@ func (c AlertCondition) String() string { // WatchWithAlert starts a conditional watch. When the condition fires, the // action is executed (notify or write). func (aw *AlertWatcher) WatchWithAlert(drv driver.Driver, pid int, vt search.ValueType, cfg AlertConfig, interval time.Duration) error { - aw.mu.Lock() - defer aw.mu.Unlock() - - if _, exists := aw.entries[cfg.Addr]; exists { - return fmt.Errorf("alert already set for 0x%x", cfg.Addr) + size := vt.Size() + if size == 0 { + return fmt.Errorf("alert does not support the %s type", vt) + } + if cfg.Condition != AlertChanged && len(cfg.Threshold) < size { + return fmt.Errorf("threshold too short for %s (need %d bytes)", vt, size) } - e := &alertEntry{cfg: cfg, drv: drv, pid: pid, vt: vt, stop: make(chan struct{})} - aw.entries[cfg.Addr] = e - - go func() { - ticker := time.NewTicker(interval) - defer ticker.Stop() + return aw.pool.Start(cfg.Addr, func(stop <-chan struct{}) { var lastVal []byte - for { - select { - case <-ticker.C: - cur, err := e.drv.Peek(e.pid, e.cfg.Addr, e.vt.Size()) - if err != nil { - continue - } + poller.EveryTick(interval, stop, func() { + cur, err := drv.Peek(pid, cfg.Addr, size) + if err != nil { + return + } - fired := false - switch e.cfg.Condition { - case AlertAbove: - fired = search.CompareValues(cur, e.cfg.Threshold, e.vt) > 0 - case AlertBelow: - fired = search.CompareValues(cur, e.cfg.Threshold, e.vt) < 0 - case AlertChanged: - if lastVal != nil { - fired = !search.EqualBytes(cur, lastVal) - } - } + fired := false + switch cfg.Condition { + case AlertAbove: + fired = search.CompareValues(cur, cfg.Threshold, vt) > 0 + case AlertBelow: + fired = search.CompareValues(cur, cfg.Threshold, vt) < 0 + case AlertChanged: + fired = lastVal != nil && !search.EqualBytes(cur, lastVal) + } - if fired { - triggered := false - if e.cfg.Action == ActionWrite && len(e.cfg.WriteVal) > 0 { - _ = e.drv.Poke(e.pid, e.cfg.Addr, e.cfg.WriteVal) - triggered = true - } - if aw.OnAlert != nil { - aw.OnAlert(AlertEvent{ - Addr: e.cfg.Addr, - Condition: e.cfg.Condition.String(), - Value: search.FormatValue(cur, e.vt), - Triggered: triggered, - }) - } + if fired { + triggered := false + if cfg.Action == ActionWrite && len(cfg.WriteVal) > 0 { + _ = drv.Poke(pid, cfg.Addr, cfg.WriteVal) + triggered = true } - - if lastVal == nil { - lastVal = make([]byte, len(cur)) + if aw.OnAlert != nil { + aw.OnAlert(AlertEvent{ + Addr: cfg.Addr, + Condition: cfg.Condition.String(), + Value: search.FormatValue(cur, vt), + Triggered: triggered, + }) } - copy(lastVal, cur) - case <-e.stop: - return } - } - }() - return nil + if lastVal == nil { + lastVal = make([]byte, len(cur)) + } + copy(lastVal, cur) + }) + }) } // RemoveAlert stops a conditional watch. -func (aw *AlertWatcher) RemoveAlert(addr uintptr) error { - aw.mu.Lock() - defer aw.mu.Unlock() - - e, ok := aw.entries[addr] - if !ok { - return fmt.Errorf("no alert set for 0x%x", addr) - } - close(e.stop) - delete(aw.entries, addr) - return nil -} +func (aw *AlertWatcher) RemoveAlert(addr uintptr) error { return aw.pool.Stop(addr) } // RemoveAll stops all alerts. -func (aw *AlertWatcher) RemoveAll() { - aw.mu.Lock() - defer aw.mu.Unlock() - - for addr, e := range aw.entries { - close(e.stop) - delete(aw.entries, addr) - } -} +func (aw *AlertWatcher) RemoveAll() { aw.pool.StopAll() } // List returns all alert addresses. -func (aw *AlertWatcher) List() []uintptr { - aw.mu.Lock() - defer aw.mu.Unlock() - - addrs := make([]uintptr, 0, len(aw.entries)) - for addr := range aw.entries { - addrs = append(addrs, addr) - } - return addrs -} +func (aw *AlertWatcher) List() []uintptr { return aw.pool.List() } diff --git a/internal/memory/watch/watch.go b/internal/memory/watch/watch.go index 5e3dce7..a3c7882 100644 --- a/internal/memory/watch/watch.go +++ b/internal/memory/watch/watch.go @@ -2,11 +2,11 @@ package watch import ( "fmt" - "sync" "time" - "memodroid/internal/driver" - "memodroid/internal/memory/search" + "memdroid/internal/driver" + "memdroid/internal/memory/search" + "memdroid/internal/poller" ) // ChangeEvent is emitted when a watched value changes. @@ -16,108 +16,59 @@ type ChangeEvent struct { Cur string } -type watchEntry struct { - drv driver.Driver - pid int - addr uintptr - vtype search.ValueType - last []byte - stop chan struct{} -} - // Watcher monitors a set of addresses and fires OnChange when a value changes. +// OnChange is invoked from watcher goroutines and must be safe for concurrent use. type Watcher struct { - mu sync.Mutex - entries map[uintptr]*watchEntry + pool *poller.Pool OnChange func(ChangeEvent) } func NewWatcher() *Watcher { - return &Watcher{entries: make(map[uintptr]*watchEntry)} + return &Watcher{pool: poller.New()} } // Watch polls addr every interval and calls OnChange when the value changes. -// Returns an error if addr is already being watched or initial read fails. +// Returns an error if addr is already being watched, the type has no fixed +// size, or the initial read fails. func (w *Watcher) Watch(drv driver.Driver, pid int, addr uintptr, vt search.ValueType, interval time.Duration) error { - w.mu.Lock() - defer w.mu.Unlock() - - if _, exists := w.entries[addr]; exists { - return fmt.Errorf("0x%x is already being watched", addr) + size := vt.Size() + if size == 0 { + return fmt.Errorf("watch does not support the %s type", vt) } - initial, err := drv.Peek(pid, addr, vt.Size()) + initial, err := drv.Peek(pid, addr, size) if err != nil { return fmt.Errorf("watch: initial read failed: %w", err) } last := make([]byte, len(initial)) copy(last, initial) - e := &watchEntry{drv: drv, pid: pid, addr: addr, vtype: vt, last: last, stop: make(chan struct{})} - w.entries[addr] = e - - go func() { - ticker := time.NewTicker(interval) - defer ticker.Stop() - for { - select { - case <-ticker.C: - cur, err := e.drv.Peek(e.pid, e.addr, e.vtype.Size()) - if err != nil { - continue - } - if !search.EqualBytes(cur, e.last) { - if w.OnChange != nil { - w.OnChange(ChangeEvent{ - Addr: e.addr, - Prev: search.FormatValue(e.last, e.vtype), - Cur: search.FormatValue(cur, e.vtype), - }) - } - copy(e.last, cur) - } - case <-e.stop: + return w.pool.Start(addr, func(stop <-chan struct{}) { + poller.EveryTick(interval, stop, func() { + cur, err := drv.Peek(pid, addr, size) + if err != nil { return } - } - }() - - return nil + if search.EqualBytes(cur, last) { + return + } + if w.OnChange != nil { + w.OnChange(ChangeEvent{ + Addr: addr, + Prev: search.FormatValue(last, vt), + Cur: search.FormatValue(cur, vt), + }) + } + copy(last, cur) + }) + }) } -// Unwatch stops watching addr. Returns an error if addr was not watched. -func (w *Watcher) Unwatch(addr uintptr) error { - w.mu.Lock() - defer w.mu.Unlock() - - e, ok := w.entries[addr] - if !ok { - return fmt.Errorf("0x%x is not being watched", addr) - } - close(e.stop) - delete(w.entries, addr) - return nil -} +// Unwatch stops watching addr. +func (w *Watcher) Unwatch(addr uintptr) error { return w.pool.Stop(addr) } // UnwatchAll stops all watchers. -func (w *Watcher) UnwatchAll() { - w.mu.Lock() - defer w.mu.Unlock() - - for addr, e := range w.entries { - close(e.stop) - delete(w.entries, addr) - } -} +func (w *Watcher) UnwatchAll() { w.pool.StopAll() } // List returns all currently watched addresses. -func (w *Watcher) List() []uintptr { - w.mu.Lock() - defer w.mu.Unlock() - - addrs := make([]uintptr, 0, len(w.entries)) - for addr := range w.entries { - addrs = append(addrs, addr) - } - return addrs -} +func (w *Watcher) List() []uintptr { return w.pool.List() } diff --git a/internal/poller/poller.go b/internal/poller/poller.go new file mode 100644 index 0000000..84b2d8c --- /dev/null +++ b/internal/poller/poller.go @@ -0,0 +1,99 @@ +// Package poller provides a small keyed goroutine manager shared by the +// freeze/watch/alert subsystems. Each of those used to carry its own copy of +// "map[uintptr]*entry + stop channel + List/Stop/StopAll" boilerplate; they now +// delegate that lifecycle management to a single Pool. +package poller + +import ( + "fmt" + "sort" + "sync" + "time" +) + +// Pool manages a set of background tasks keyed by address. Each task runs on its +// own goroutine and stops when its stop channel is closed. +type Pool struct { + mu sync.Mutex + tasks map[uintptr]chan struct{} +} + +// New returns an empty Pool. +func New() *Pool { + return &Pool{tasks: make(map[uintptr]chan struct{})} +} + +// Start launches run on a new goroutine associated with key. run must return +// when its stop channel is closed. Start returns an error if key is already +// running. +func (p *Pool) Start(key uintptr, run func(stop <-chan struct{})) error { + p.mu.Lock() + if _, exists := p.tasks[key]; exists { + p.mu.Unlock() + return fmt.Errorf("0x%x is already active", key) + } + stop := make(chan struct{}) + p.tasks[key] = stop + p.mu.Unlock() + + go run(stop) + return nil +} + +// Stop stops the task for key. Returns an error if key is not running. +func (p *Pool) Stop(key uintptr) error { + p.mu.Lock() + defer p.mu.Unlock() + stop, ok := p.tasks[key] + if !ok { + return fmt.Errorf("0x%x is not active", key) + } + close(stop) + delete(p.tasks, key) + return nil +} + +// StopAll stops every running task. +func (p *Pool) StopAll() { + p.mu.Lock() + defer p.mu.Unlock() + for key, stop := range p.tasks { + close(stop) + delete(p.tasks, key) + } +} + +// Has reports whether key is currently running. +func (p *Pool) Has(key uintptr) bool { + p.mu.Lock() + defer p.mu.Unlock() + _, ok := p.tasks[key] + return ok +} + +// List returns all active keys, sorted ascending. +func (p *Pool) List() []uintptr { + p.mu.Lock() + defer p.mu.Unlock() + keys := make([]uintptr, 0, len(p.tasks)) + for key := range p.tasks { + keys = append(keys, key) + } + sort.Slice(keys, func(i, j int) bool { return keys[i] < keys[j] }) + return keys +} + +// EveryTick invokes onTick on each interval until stop is closed. It is a helper +// for building run functions passed to Start. +func EveryTick(interval time.Duration, stop <-chan struct{}, onTick func()) { + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ticker.C: + onTick() + case <-stop: + return + } + } +} diff --git a/internal/poller/poller_test.go b/internal/poller/poller_test.go new file mode 100644 index 0000000..f9a3d0e --- /dev/null +++ b/internal/poller/poller_test.go @@ -0,0 +1,63 @@ +package poller + +import "testing" + +func TestPoolLifecycle(t *testing.T) { + p := New() + + started := make(chan struct{}) + done := make(chan struct{}) + + err := p.Start(0x10, func(stop <-chan struct{}) { + close(started) + <-stop + close(done) + }) + if err != nil { + t.Fatalf("Start: %v", err) + } + <-started + + if !p.Has(0x10) { + t.Errorf("Has(0x10) should be true") + } + if got := p.List(); len(got) != 1 || got[0] != 0x10 { + t.Errorf("List = %v, want [0x10]", got) + } + + // Starting the same key again must fail. + if err := p.Start(0x10, func(<-chan struct{}) {}); err == nil { + t.Errorf("duplicate Start should error") + } + + if err := p.Stop(0x10); err != nil { + t.Fatalf("Stop: %v", err) + } + <-done // task goroutine observed the stop signal + if p.Has(0x10) { + t.Errorf("Has(0x10) should be false after Stop") + } + if err := p.Stop(0x10); err == nil { + t.Errorf("Stop of missing key should error") + } +} + +func TestPoolListSortedAndStopAll(t *testing.T) { + p := New() + for _, k := range []uintptr{0x30, 0x10, 0x20} { + if err := p.Start(k, func(stop <-chan struct{}) { <-stop }); err != nil { + t.Fatalf("Start(0x%x): %v", k, err) + } + } + got := p.List() + want := []uintptr{0x10, 0x20, 0x30} + for i := range want { + if got[i] != want[i] { + t.Fatalf("List = %v, want %v", got, want) + } + } + p.StopAll() + if len(p.List()) != 0 { + t.Errorf("StopAll should clear all tasks") + } +} diff --git a/internal/process/list.go b/internal/process/list.go index 1e63b45..f3d87cf 100644 --- a/internal/process/list.go +++ b/internal/process/list.go @@ -3,7 +3,7 @@ package process import ( "fmt" - "memodroid/internal/driver" + "memdroid/internal/driver" ) // ProcessInfo mirrors driver.ProcessInfo with JSON tags for the HTTP API. diff --git a/internal/server/handlers.go b/internal/server/handlers.go index 977ce74..d1ba372 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -6,16 +6,20 @@ import ( "net/http" "sort" "strconv" + "strings" "time" - "memodroid/internal/app" - "memodroid/internal/driver/adb" - "memodroid/internal/memory/pointer" - "memodroid/internal/memory/search" - "memodroid/internal/memory/store" - "memodroid/internal/process" + "memdroid/internal/app" + "memdroid/internal/driver" + "memdroid/internal/driver/adb" + "memdroid/internal/memory/pointer" + "memdroid/internal/memory/search" + "memdroid/internal/memory/store" + "memdroid/internal/process" ) +const defaultStateFile = "memdroid.json" + type handler struct { state *app.State adb *adb.ADB @@ -34,8 +38,23 @@ func writeError(w http.ResponseWriter, code int, msg string) { _ = json.NewEncoder(w).Encode(map[string]string{"error": msg}) } -func decode(r *http.Request, v any) error { - return json.NewDecoder(r.Body).Decode(v) +func writeOK(w http.ResponseWriter, extra map[string]any) { + out := map[string]any{"ok": true} + for k, v := range extra { + out[k] = v + } + writeJSON(w, out) +} + +// decodeJSON decodes the request body into a value of type T. On failure it +// writes a 400 response and returns ok=false. +func decodeJSON[T any](w http.ResponseWriter, r *http.Request) (T, bool) { + var v T + if err := json.NewDecoder(r.Body).Decode(&v); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON body: "+err.Error()) + return v, false + } + return v, true } func parseHexAddr(s string) (uintptr, error) { @@ -43,10 +62,30 @@ func parseHexAddr(s string) (uintptr, error) { return uintptr(v), err } -func requirePID(w http.ResponseWriter, h *handler) (int, bool) { +// requireAddr parses a hex address, writing a 400 on failure. +func requireAddr(w http.ResponseWriter, s string) (uintptr, bool) { + addr, err := parseHexAddr(s) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid addr") + return 0, false + } + return addr, true +} + +// requireValue parses a typed value string, writing a 400 on failure. +func requireValue(w http.ResponseWriter, s string, vt search.ValueType) ([]byte, bool) { + val, err := search.ParseValue(s, vt) + if err != nil { + writeError(w, http.StatusBadRequest, fmt.Sprintf("invalid value: %v", err)) + return nil, false + } + return val, true +} + +func (h *handler) requirePID(w http.ResponseWriter) (int, bool) { pid := h.state.GetPID() if pid == 0 { - writeError(w, 400, "not attached") + writeError(w, http.StatusBadRequest, "not attached") return 0, false } return pid, true @@ -80,7 +119,7 @@ func (h *handler) status(w http.ResponseWriter, _ *http.Request) { func (h *handler) deviceList(w http.ResponseWriter, _ *http.Request) { devices, err := h.adb.ListDevices() if err != nil { - writeError(w, 500, err.Error()) + writeError(w, http.StatusInternalServerError, err.Error()) return } type entry struct { @@ -94,48 +133,57 @@ func (h *handler) deviceList(w http.ResponseWriter, _ *http.Request) { } func (h *handler) deviceSelect(w http.ResponseWriter, r *http.Request) { - var req struct { + req, ok := decodeJSON[struct { Serial string `json:"serial"` + }](w, r) + if !ok { + return } - if err := decode(r, &req); err != nil || req.Serial == "" { - writeError(w, 400, "serial required") + if req.Serial == "" { + writeError(w, http.StatusBadRequest, "serial required") return } if err := h.adb.SelectDevice(req.Serial); err != nil { - writeError(w, 500, err.Error()) + writeError(w, http.StatusInternalServerError, err.Error()) return } - writeJSON(w, map[string]any{"ok": true, "serial": req.Serial}) + writeOK(w, map[string]any{"serial": req.Serial}) } func (h *handler) deviceConnectWifi(w http.ResponseWriter, r *http.Request) { - var req struct { + req, ok := decodeJSON[struct { Addr string `json:"addr"` + }](w, r) + if !ok { + return } - if err := decode(r, &req); err != nil || req.Addr == "" { - writeError(w, 400, "addr required (host:port)") + if req.Addr == "" { + writeError(w, http.StatusBadRequest, "addr required (host:port)") return } if err := h.adb.ConnectWifi(req.Addr); err != nil { - writeError(w, 500, err.Error()) + writeError(w, http.StatusInternalServerError, err.Error()) return } - writeJSON(w, map[string]any{"ok": true, "serial": req.Addr}) + writeOK(w, map[string]any{"serial": req.Addr}) } func (h *handler) deviceDisconnectWifi(w http.ResponseWriter, r *http.Request) { - var req struct { + req, ok := decodeJSON[struct { Addr string `json:"addr"` + }](w, r) + if !ok { + return } - if err := decode(r, &req); err != nil || req.Addr == "" { - writeError(w, 400, "addr required") + if req.Addr == "" { + writeError(w, http.StatusBadRequest, "addr required") return } if err := h.adb.DisconnectWifi(req.Addr); err != nil { - writeError(w, 500, err.Error()) + writeError(w, http.StatusInternalServerError, err.Error()) return } - writeJSON(w, map[string]any{"ok": true}) + writeOK(w, nil) } // --- process --- @@ -143,23 +191,26 @@ func (h *handler) deviceDisconnectWifi(w http.ResponseWriter, r *http.Request) { func (h *handler) processList(w http.ResponseWriter, _ *http.Request) { procs, err := process.ProcessList(h.state.GetDriver()) if err != nil { - writeError(w, 500, err.Error()) + writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, procs) } func (h *handler) processSearch(w http.ResponseWriter, r *http.Request) { - var req struct { + req, ok := decodeJSON[struct { Name string `json:"name"` + }](w, r) + if !ok { + return } - if err := decode(r, &req); err != nil || req.Name == "" { - writeError(w, 400, "name required") + if req.Name == "" { + writeError(w, http.StatusBadRequest, "name required") return } matches, err := h.adb.FindProcessByName(req.Name) if err != nil { - writeError(w, 500, err.Error()) + writeError(w, http.StatusInternalServerError, err.Error()) return } type entry struct { @@ -174,69 +225,74 @@ func (h *handler) processSearch(w http.ResponseWriter, r *http.Request) { } func (h *handler) processAttach(w http.ResponseWriter, r *http.Request) { - var req struct { + req, ok := decodeJSON[struct { PID int `json:"pid"` + }](w, r) + if !ok { + return } - if err := decode(r, &req); err != nil || req.PID == 0 { - writeError(w, 400, "invalid pid") + if req.PID == 0 { + writeError(w, http.StatusBadRequest, "invalid pid") return } drv := h.state.GetDriver() if err := drv.Attach(req.PID); err != nil { - writeError(w, 500, err.Error()) + writeError(w, http.StatusInternalServerError, err.Error()) return } h.state.SetPID(req.PID) h.state.SetSession(search.NewSession(req.PID, h.state.GetValueType(), drv)) - writeJSON(w, map[string]any{"ok": true, "pid": req.PID}) + writeOK(w, map[string]any{"pid": req.PID}) } func (h *handler) processDetach(w http.ResponseWriter, _ *http.Request) { - pid, ok := requirePID(w, h) + pid, ok := h.requirePID(w) if !ok { return } h.state.Freezer.UnfreezeAll() + h.state.Watcher.UnwatchAll() + h.state.AlertWatcher.RemoveAll() h.state.GetDriver().Detach(pid) h.state.SetPID(0) h.state.SetSession(nil) - writeJSON(w, map[string]any{"ok": true}) + writeOK(w, nil) } func (h *handler) processStop(w http.ResponseWriter, _ *http.Request) { - pid, ok := requirePID(w, h) + pid, ok := h.requirePID(w) if !ok { return } if err := h.state.GetDriver().Stop(pid); err != nil { - writeError(w, 500, err.Error()) + writeError(w, http.StatusInternalServerError, err.Error()) return } - writeJSON(w, map[string]any{"ok": true}) + writeOK(w, nil) } func (h *handler) processContinue(w http.ResponseWriter, _ *http.Request) { - pid, ok := requirePID(w, h) + pid, ok := h.requirePID(w) if !ok { return } if err := h.state.GetDriver().Continue(pid); err != nil { - writeError(w, 500, err.Error()) + writeError(w, http.StatusInternalServerError, err.Error()) return } - writeJSON(w, map[string]any{"ok": true}) + writeOK(w, nil) } // --- maps --- func (h *handler) mapsList(w http.ResponseWriter, _ *http.Request) { - pid, ok := requirePID(w, h) + pid, ok := h.requirePID(w) if !ok { return } regions, err := h.state.GetDriver().ReadMaps(pid) if err != nil { - writeError(w, 500, err.Error()) + writeError(w, http.StatusInternalServerError, err.Error()) return } type entry struct { @@ -260,17 +316,14 @@ func (h *handler) mapsList(w http.ResponseWriter, _ *http.Request) { // --- search --- func (h *handler) searchValue(w http.ResponseWriter, r *http.Request) { - pid, ok := requirePID(w, h) - if !ok { + if _, ok := h.requirePID(w); !ok { return } - _ = pid - var req struct { + req, ok := decodeJSON[struct { Value string `json:"value"` Type string `json:"type"` - } - if err := decode(r, &req); err != nil { - writeError(w, 400, err.Error()) + }](w, r) + if !ok { return } vt := h.state.GetValueType() @@ -280,101 +333,137 @@ func (h *handler) searchValue(w http.ResponseWriter, r *http.Request) { h.state.SetValueType(vt) } } - val, err := search.ParseValue(req.Value, vt) - if err != nil { - writeError(w, 400, fmt.Sprintf("invalid value: %v", err)) + val, ok := requireValue(w, req.Value, vt) + if !ok { return } sess := h.state.EnsureSession() if err := sess.Search(val); err != nil { - writeError(w, 500, err.Error()) + writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, map[string]any{"candidates": sess.CandidateCount()}) } func (h *handler) searchPattern(w http.ResponseWriter, r *http.Request) { - pid, ok := requirePID(w, h) + pid, ok := h.requirePID(w) if !ok { return } - var req struct { + req, ok := decodeJSON[struct { Pattern string `json:"pattern"` - } - if err := decode(r, &req); err != nil { - writeError(w, 400, err.Error()) + }](w, r) + if !ok { return } pat, err := search.ParsePattern(req.Pattern) if err != nil { - writeError(w, 400, err.Error()) + writeError(w, http.StatusBadRequest, err.Error()) return } - results, err := search.SearchPattern(h.state.GetDriver(), pid, pat) + drv := h.state.GetDriver() + results, err := search.SearchPattern(drv, pid, pat) if err != nil { - writeError(w, 500, err.Error()) + writeError(w, http.StatusInternalServerError, err.Error()) return } - writeJSON(w, map[string]any{"count": len(results)}) + cands := h.candidatesFromAddrs(drv, pid, results, len(pat)) + h.storeByteCandidates(cands) + writeJSON(w, map[string]any{ + "count": len(results), + "candidates": len(cands), + "truncated": len(results) >= search.PatternMaxResults, + }) } func (h *handler) searchString(w http.ResponseWriter, r *http.Request) { - pid, ok := requirePID(w, h) + pid, ok := h.requirePID(w) if !ok { return } - var req struct { + req, ok := decodeJSON[struct { Value string `json:"value"` Encoding string `json:"encoding"` - } - if err := decode(r, &req); err != nil { - writeError(w, 400, err.Error()) + }](w, r) + if !ok { return } drv := h.state.GetDriver() + utf16 := req.Encoding == "utf16" var results []uintptr var err error - if req.Encoding == "utf16" { + if utf16 { results, err = search.SearchStringUTF16(drv, pid, req.Value) } else { results, err = search.SearchStringUTF8(drv, pid, req.Value) } if err != nil { - writeError(w, 500, err.Error()) + writeError(w, http.StatusInternalServerError, err.Error()) return } - writeJSON(w, map[string]any{"count": len(results)}) + valBytes := search.StringBytes(req.Value, utf16) + cands := make(map[uintptr][]byte, len(results)) + for _, a := range results { + b := make([]byte, len(valBytes)) + copy(b, valBytes) + cands[a] = b + } + h.storeByteCandidates(cands) + writeJSON(w, map[string]any{ + "count": len(results), + "candidates": len(cands), + "truncated": len(results) >= search.PatternMaxResults, + }) +} + +// candidatesFromAddrs reads width bytes at each address to record the matched +// value alongside its address. +func (h *handler) candidatesFromAddrs(drv driver.Driver, pid int, addrs []uintptr, width int) map[uintptr][]byte { + cands := make(map[uintptr][]byte, len(addrs)) + for _, a := range addrs { + if b, err := drv.Peek(pid, a, width); err == nil { + cands[a] = b + } + } + return cands +} + +// storeByteCandidates switches the active session/value type to bytes and loads +// the given candidates so they can be paged, filtered and frozen. +func (h *handler) storeByteCandidates(cands map[uintptr][]byte) { + h.state.SetValueType(search.TypeBytes) + sess := h.state.EnsureSession() + sess.ValueType = search.TypeBytes + sess.SetCandidates(cands) } func (h *handler) searchFilter(w http.ResponseWriter, r *http.Request) { sess := h.state.GetSession() if sess == nil || !sess.HasCandidates() { - writeError(w, 400, "no active session") + writeError(w, http.StatusBadRequest, "no active session") return } - var req struct { + req, ok := decodeJSON[struct { Mode string `json:"mode"` Value string `json:"value"` - } - if err := decode(r, &req); err != nil { - writeError(w, 400, err.Error()) + }](w, r) + if !ok { return } mode, err := search.ParseFilterMode(req.Mode) if err != nil { - writeError(w, 400, err.Error()) + writeError(w, http.StatusBadRequest, err.Error()) return } var target []byte if mode == search.FilterValue { - target, err = search.ParseValue(req.Value, h.state.GetValueType()) - if err != nil { - writeError(w, 400, fmt.Sprintf("invalid value: %v", err)) + target, ok = requireValue(w, req.Value, h.state.GetValueType()) + if !ok { return } } if err := sess.Filter(mode, target); err != nil { - writeError(w, 400, err.Error()) + writeError(w, http.StatusBadRequest, err.Error()) return } writeJSON(w, map[string]any{"candidates": sess.CandidateCount()}) @@ -388,18 +477,8 @@ func (h *handler) searchCandidates(w http.ResponseWriter, r *http.Request) { return } - pageSize := 100 - page := 0 - if s := r.URL.Query().Get("page_size"); s != "" { - if v, err := strconv.Atoi(s); err == nil && v > 0 { - pageSize = v - } - } - if s := r.URL.Query().Get("page"); s != "" { - if v, err := strconv.Atoi(s); err == nil && v >= 0 { - page = v - } - } + pageSize := queryInt(r, "page_size", 100, 1) + page := queryInt(r, "page", 0, 0) snap := sess.Snapshot() addrs := make([]uintptr, 0, len(snap)) @@ -438,52 +517,63 @@ func (h *handler) searchCandidates(w http.ResponseWriter, r *http.Request) { }) } +// queryInt parses a query parameter as an int, returning def if absent/invalid +// or below min. +func queryInt(r *http.Request, key string, def, min int) int { + if s := r.URL.Query().Get(key); s != "" { + if v, err := strconv.Atoi(s); err == nil && v >= min { + return v + } + } + return def +} + func (h *handler) searchReset(w http.ResponseWriter, _ *http.Request) { if sess := h.state.GetSession(); sess != nil { sess.Reset() } - writeJSON(w, map[string]any{"ok": true}) + writeOK(w, nil) } // --- pointer scan --- func (h *handler) pointerScan(w http.ResponseWriter, r *http.Request) { - pid, ok := requirePID(w, h) + pid, ok := h.requirePID(w) if !ok { return } - var req struct { + req, ok := decodeJSON[struct { Addr string `json:"addr"` MaxDepth int `json:"max_depth"` MaxOffset int `json:"max_offset"` - } - if err := decode(r, &req); err != nil { - writeError(w, 400, err.Error()) + }](w, r) + if !ok { return } - addr, err := parseHexAddr(req.Addr) - if err != nil { - writeError(w, 400, "invalid addr") + addr, ok := requireAddr(w, req.Addr) + if !ok { return } result, err := pointer.Scan(h.state.GetDriver(), pid, addr, req.MaxDepth, uintptr(req.MaxOffset)) if err != nil { - writeError(w, 500, err.Error()) + writeError(w, http.StatusInternalServerError, err.Error()) return } type chainJSON struct { - Base string `json:"base"` - Label string `json:"label"` - Offsets []int64 `json:"offsets"` - Path string `json:"path"` + Base string `json:"base"` + Label string `json:"label"` + BaseOffset string `json:"base_offset"` + Offsets []int64 `json:"offsets"` + Path string `json:"path"` } out := make([]chainJSON, len(result.Chains)) for i, c := range result.Chains { out[i] = chainJSON{ - Base: fmt.Sprintf("0x%x", c.BaseAddr), - Label: c.BaseLabel, - Offsets: c.Offsets, - Path: pointer.FormatChain(c), + Base: fmt.Sprintf("0x%x", c.BaseAddr), + Label: c.BaseLabel, + BaseOffset: fmt.Sprintf("0x%x", c.BaseOffset), + Offsets: c.Offsets, + Path: pointer.FormatChain(c), } } writeJSON(w, map[string]any{"target": fmt.Sprintf("0x%x", addr), "chains": out}) @@ -492,154 +582,151 @@ func (h *handler) pointerScan(w http.ResponseWriter, r *http.Request) { // --- pointer resolve --- func (h *handler) pointerResolve(w http.ResponseWriter, r *http.Request) { - pid, ok := requirePID(w, h) - if !ok { + if _, ok := h.requirePID(w); !ok { return } - _ = pid - var req struct { - Label string `json:"label"` - Offsets []int64 `json:"offsets"` - } - if err := decode(r, &req); err != nil { - writeError(w, 400, err.Error()) + req, ok := decodeJSON[struct { + Label string `json:"label"` + BaseOffset string `json:"base_offset"` + Offsets []int64 `json:"offsets"` + }](w, r) + if !ok { return } if req.Label == "" || len(req.Offsets) == 0 { - writeError(w, 400, "label and offsets required") + writeError(w, http.StatusBadRequest, "label and offsets required") return } + var baseOffset uintptr + if req.BaseOffset != "" { + baseOffset, ok = requireAddr(w, req.BaseOffset) + if !ok { + return + } + } chain := pointer.Chain{ - BaseLabel: req.Label, - Offsets: req.Offsets, + BaseLabel: req.Label, + BaseOffset: baseOffset, + Offsets: req.Offsets, } resolved, err := pointer.ResolveChain(h.state.GetDriver(), h.state.GetPID(), chain) if err != nil { - writeError(w, 500, err.Error()) + writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, map[string]any{ - "resolved": fmt.Sprintf("0x%x", resolved), - "label": req.Label, - "offsets": req.Offsets, + "resolved": fmt.Sprintf("0x%x", resolved), + "label": req.Label, + "base_offset": req.BaseOffset, + "offsets": req.Offsets, }) } // --- memory --- func (h *handler) memoryModify(w http.ResponseWriter, r *http.Request) { - pid, ok := requirePID(w, h) + pid, ok := h.requirePID(w) if !ok { return } - var req struct { + req, ok := decodeJSON[struct { Addr string `json:"addr"` Value string `json:"value"` - } - if err := decode(r, &req); err != nil { - writeError(w, 400, err.Error()) + }](w, r) + if !ok { return } - addr, err := parseHexAddr(req.Addr) - if err != nil { - writeError(w, 400, "invalid addr") + addr, ok := requireAddr(w, req.Addr) + if !ok { return } - vt := h.state.GetValueType() - val, err := search.ParseValue(req.Value, vt) - if err != nil { - writeError(w, 400, fmt.Sprintf("invalid value: %v", err)) + val, ok := requireValue(w, req.Value, h.state.GetValueType()) + if !ok { return } - if err := h.state.UndoStack.WithUndo(h.state.GetDriver(), pid, addr, val, vt); err != nil { - writeError(w, 500, err.Error()) + if err := h.state.UndoStack.WithUndo(h.state.GetDriver(), pid, addr, val); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) return } - writeJSON(w, map[string]any{"ok": true}) + writeOK(w, nil) } func (h *handler) memoryUndo(w http.ResponseWriter, _ *http.Request) { if err := h.state.UndoStack.Undo(); err != nil { - writeError(w, 400, err.Error()) + writeError(w, http.StatusBadRequest, err.Error()) return } - writeJSON(w, map[string]any{"ok": true, "undo_depth": h.state.UndoStack.Depth()}) + writeOK(w, map[string]any{"undo_depth": h.state.UndoStack.Depth()}) } func (h *handler) memoryFreeze(w http.ResponseWriter, r *http.Request) { - pid, ok := requirePID(w, h) + pid, ok := h.requirePID(w) if !ok { return } - var req struct { + req, ok := decodeJSON[struct { Addr string `json:"addr"` Value string `json:"value"` - } - if err := decode(r, &req); err != nil { - writeError(w, 400, err.Error()) + }](w, r) + if !ok { return } - addr, err := parseHexAddr(req.Addr) - if err != nil { - writeError(w, 400, "invalid addr") + addr, ok := requireAddr(w, req.Addr) + if !ok { return } - val, err := search.ParseValue(req.Value, h.state.GetValueType()) - if err != nil { - writeError(w, 400, fmt.Sprintf("invalid value: %v", err)) + val, ok := requireValue(w, req.Value, h.state.GetValueType()) + if !ok { return } if err := h.state.Freezer.Freeze(h.state.GetDriver(), pid, addr, val); err != nil { - writeError(w, 400, err.Error()) + writeError(w, http.StatusBadRequest, err.Error()) return } - writeJSON(w, map[string]any{"ok": true}) + writeOK(w, nil) } func (h *handler) freezeSetInterval(w http.ResponseWriter, r *http.Request) { - var req struct { + req, ok := decodeJSON[struct { IntervalMs int `json:"interval_ms"` - } - if err := decode(r, &req); err != nil { - writeError(w, 400, err.Error()) + }](w, r) + if !ok { return } if req.IntervalMs <= 0 { - writeError(w, 400, "interval_ms must be positive") + writeError(w, http.StatusBadRequest, "interval_ms must be positive") return } h.state.Freezer.SetInterval(time.Duration(req.IntervalMs) * time.Millisecond) - writeJSON(w, map[string]any{"ok": true, "interval_ms": req.IntervalMs}) + writeOK(w, map[string]any{"interval_ms": req.IntervalMs}) } func (h *handler) memoryFreezeAll(w http.ResponseWriter, _ *http.Request) { sess := h.state.GetSession() if sess == nil || !sess.HasCandidates() { - writeError(w, 400, "no candidates") + writeError(w, http.StatusBadRequest, "no candidates") return } count := h.state.Freezer.FreezeAllCandidates(h.state.GetDriver(), sess) - writeJSON(w, map[string]any{"ok": true, "count": count}) + writeOK(w, map[string]any{"count": count}) } func (h *handler) memoryUnfreeze(w http.ResponseWriter, r *http.Request) { - var req struct { + req, ok := decodeJSON[struct { Addr string `json:"addr"` - } - if err := decode(r, &req); err != nil { - writeError(w, 400, err.Error()) + }](w, r) + if !ok { return } - addr, err := parseHexAddr(req.Addr) - if err != nil { - writeError(w, 400, "invalid addr") + addr, ok := requireAddr(w, req.Addr) + if !ok { return } if err := h.state.Freezer.Unfreeze(addr); err != nil { - writeError(w, 400, err.Error()) + writeError(w, http.StatusBadRequest, err.Error()) return } - writeJSON(w, map[string]any{"ok": true}) + writeOK(w, nil) } func (h *handler) memoryFrozen(w http.ResponseWriter, _ *http.Request) { @@ -654,29 +741,28 @@ func (h *handler) memoryFrozen(w http.ResponseWriter, _ *http.Request) { // --- hexdump --- func (h *handler) memoryHexdump(w http.ResponseWriter, r *http.Request) { - pid, ok := requirePID(w, h) + pid, ok := h.requirePID(w) if !ok { return } addrStr := r.URL.Query().Get("addr") sizeStr := r.URL.Query().Get("size") if addrStr == "" || sizeStr == "" { - writeError(w, 400, "addr and size required") + writeError(w, http.StatusBadRequest, "addr and size required") return } - addr, err := parseHexAddr(addrStr) - if err != nil { - writeError(w, 400, "invalid addr") + addr, ok := requireAddr(w, addrStr) + if !ok { return } size, err := strconv.Atoi(sizeStr) if err != nil || size <= 0 || size > 4096 { - writeError(w, 400, "size must be 1-4096") + writeError(w, http.StatusBadRequest, "size must be 1-4096") return } data, err := h.state.GetDriver().ReadRegion(pid, addr, size) if err != nil { - writeError(w, 500, err.Error()) + writeError(w, http.StatusInternalServerError, err.Error()) return } @@ -693,32 +779,19 @@ func (h *handler) memoryHexdump(w http.ResponseWriter, r *http.Request) { } chunk := data[i:end] - // Build hex string hexParts := make([]string, len(chunk)) - for j, b := range chunk { - hexParts[j] = fmt.Sprintf("%02x", b) - } - hexStr := "" - for j, p := range hexParts { - if j > 0 { - hexStr += " " - } - hexStr += p - } - - // Build ASCII string ascii := make([]byte, len(chunk)) for j, b := range chunk { + hexParts[j] = fmt.Sprintf("%02x", b) if b >= 0x20 && b <= 0x7e { ascii[j] = b } else { ascii[j] = '.' } } - lines = append(lines, hexLine{ Offset: i, - Hex: hexStr, + Hex: strings.Join(hexParts, " "), ASCII: string(ascii), }) } @@ -731,70 +804,62 @@ func (h *handler) memoryHexdump(w http.ResponseWriter, r *http.Request) { // --- snapshot --- func (h *handler) snapshotTake(w http.ResponseWriter, r *http.Request) { - pid, ok := requirePID(w, h) + pid, ok := h.requirePID(w) if !ok { return } - var req struct { + req, ok := decodeJSON[struct { Addr string `json:"addr"` Size int `json:"size"` - } - if err := decode(r, &req); err != nil { - writeError(w, 400, err.Error()) + }](w, r) + if !ok { return } - addr, err := parseHexAddr(req.Addr) - if err != nil { - writeError(w, 400, "invalid addr") + addr, ok := requireAddr(w, req.Addr) + if !ok { return } if req.Size <= 0 { - writeError(w, 400, "size must be positive") + writeError(w, http.StatusBadRequest, "size must be positive") return } - drv := h.state.GetDriver() - data, readErr := drv.ReadRegion(pid, addr, req.Size) - if readErr != nil { - writeError(w, 500, readErr.Error()) + data, err := h.state.GetDriver().ReadRegion(pid, addr, req.Size) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) return } - // Store snapshot in state for later diff h.state.SetSnapshot(addr, data) - writeJSON(w, map[string]any{"ok": true, "addr": fmt.Sprintf("0x%x", addr), "size": len(data)}) + writeOK(w, map[string]any{"addr": fmt.Sprintf("0x%x", addr), "size": len(data)}) } func (h *handler) snapshotDiff(w http.ResponseWriter, r *http.Request) { - pid, ok := requirePID(w, h) + pid, ok := h.requirePID(w) if !ok { return } - _ = pid - var req struct { + req, ok := decodeJSON[struct { Addr string `json:"addr"` Size int `json:"size"` - } - if err := decode(r, &req); err != nil { - writeError(w, 400, err.Error()) + }](w, r) + if !ok { return } - addr, err := parseHexAddr(req.Addr) - if err != nil { - writeError(w, 400, "invalid addr") + addr, ok := requireAddr(w, req.Addr) + if !ok { return } if req.Size <= 0 { - writeError(w, 400, "size must be positive") + writeError(w, http.StatusBadRequest, "size must be positive") return } prev := h.state.GetSnapshot(addr) if prev == nil { - writeError(w, 400, "no snapshot taken for this address — call /api/snapshot/take first") + writeError(w, http.StatusBadRequest, "no snapshot taken for this address — call /api/snapshot/take first") return } - drv := h.state.GetDriver() - cur, readErr := drv.ReadRegion(h.state.GetPID(), addr, req.Size) - if readErr != nil { - writeError(w, 500, readErr.Error()) + cur, err := h.state.GetDriver().ReadRegion(pid, addr, req.Size) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) return } minLen := len(prev) @@ -818,8 +883,6 @@ func (h *handler) snapshotDiff(w http.ResponseWriter, r *http.Request) { }) } } - // Update stored snapshot to current - h.state.SetSnapshot(addr, cur) writeJSON(w, map[string]any{"total": len(diffs), "diffs": diffs}) } @@ -852,114 +915,111 @@ func (h *handler) bookmarkList(w http.ResponseWriter, _ *http.Request) { } func (h *handler) bookmarkAdd(w http.ResponseWriter, r *http.Request) { - var req struct { + req, ok := decodeJSON[struct { Addr string `json:"addr"` Label string `json:"label"` - } - if err := decode(r, &req); err != nil { - writeError(w, 400, err.Error()) + }](w, r) + if !ok { return } - addr, err := parseHexAddr(req.Addr) - if err != nil { - writeError(w, 400, "invalid addr") + addr, ok := requireAddr(w, req.Addr) + if !ok { return } h.state.GetBookmarks().Add(addr, req.Label, h.state.GetValueType()) - writeJSON(w, map[string]any{"ok": true}) + writeOK(w, nil) } func (h *handler) bookmarkRemove(w http.ResponseWriter, r *http.Request) { - var req struct { + req, ok := decodeJSON[struct { Index int `json:"index"` - } - if err := decode(r, &req); err != nil { - writeError(w, 400, err.Error()) + }](w, r) + if !ok { return } if err := h.state.GetBookmarks().Remove(req.Index); err != nil { - writeError(w, 400, err.Error()) + writeError(w, http.StatusBadRequest, err.Error()) return } - writeJSON(w, map[string]any{"ok": true}) + writeOK(w, nil) } func (h *handler) bookmarkModifyAll(w http.ResponseWriter, r *http.Request) { - pid, ok := requirePID(w, h) + pid, ok := h.requirePID(w) if !ok { return } - var req struct { + req, ok := decodeJSON[struct { Value string `json:"value"` - } - if err := decode(r, &req); err != nil { - writeError(w, 400, err.Error()) + }](w, r) + if !ok { return } vt := h.state.GetValueType() - val, err := search.ParseValue(req.Value, vt) - if err != nil { - writeError(w, 400, fmt.Sprintf("invalid value: %v", err)) + val, ok := requireValue(w, req.Value, vt) + if !ok { return } count := h.state.GetBookmarks().ModifyAll(h.state.GetDriver(), pid, val, vt) - writeJSON(w, map[string]any{"ok": true, "count": count}) + writeOK(w, map[string]any{"count": count}) } // --- import --- func (h *handler) importCT(w http.ResponseWriter, r *http.Request) { - var req struct { + req, ok := decodeJSON[struct { Path string `json:"path"` + }](w, r) + if !ok { + return } - if err := decode(r, &req); err != nil || req.Path == "" { - writeError(w, 400, "path required") + if req.Path == "" { + writeError(w, http.StatusBadRequest, "path required") return } bookmarks, err := store.ImportCT(req.Path) if err != nil { - writeError(w, 500, err.Error()) + writeError(w, http.StatusInternalServerError, err.Error()) return } bl := h.state.GetBookmarks() for _, b := range bookmarks { bl.Add(b.Addr, b.Label, b.VType) } - writeJSON(w, map[string]any{"ok": true, "imported": len(bookmarks)}) + writeOK(w, map[string]any{"imported": len(bookmarks)}) } // --- session --- func (h *handler) sessionSave(w http.ResponseWriter, r *http.Request) { - var req struct { + req, _ := decodeJSON[struct { Path string `json:"path"` - } - if err := decode(r, &req); err != nil || req.Path == "" { - req.Path = "memdroid.json" + }](w, r) + if req.Path == "" { + req.Path = defaultStateFile } if err := store.SaveState(req.Path, h.state.GetBookmarks(), h.state.GetSession()); err != nil { - writeError(w, 500, err.Error()) + writeError(w, http.StatusInternalServerError, err.Error()) return } - writeJSON(w, map[string]any{"ok": true, "path": req.Path}) + writeOK(w, map[string]any{"path": req.Path}) } func (h *handler) sessionLoad(w http.ResponseWriter, r *http.Request) { - var req struct { + req, _ := decodeJSON[struct { Path string `json:"path"` + }](w, r) + if req.Path == "" { + req.Path = defaultStateFile } - if err := decode(r, &req); err != nil || req.Path == "" { - req.Path = "memdroid.json" - } - sess := h.state.GetSession() - bl := h.state.GetBookmarks() - if err := store.LoadState(req.Path, bl, &sess); err != nil { - writeError(w, 500, err.Error()) + loaded, err := store.LoadState(req.Path, h.state.GetBookmarks()) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) return } - if sess != nil { - sess.Driver = h.state.GetDriver() + if loaded != nil { + loaded.Driver = h.state.GetDriver() } - h.state.SetSession(sess) - writeJSON(w, map[string]any{"ok": true, "path": req.Path}) + h.state.SetSession(loaded) + writeOK(w, map[string]any{"path": req.Path}) } diff --git a/internal/server/server.go b/internal/server/server.go index 93f51cd..5d4b45b 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -5,21 +5,27 @@ import ( "fmt" "io/fs" "net/http" + "strings" + "time" "golang.org/x/net/websocket" - "memodroid/internal/app" - "memodroid/internal/driver/adb" - "memodroid/internal/memory/watch" - "memodroid/internal/server/wswatch" + "memdroid/internal/app" + "memdroid/internal/driver/adb" + "memdroid/internal/memory/watch" + "memdroid/internal/server/wswatch" ) //go:embed static var staticFiles embed.FS -// Start launches the HTTP server on addr (e.g. ":8080") in the foreground. -// Call it in a goroutine from main. -func Start(addr string, state *app.State, d *adb.ADB) error { +const maxBodyBytes = 1 << 20 // 1 MiB request-body cap + +// Start launches the HTTP server on addr (e.g. "127.0.0.1:8080") in the +// foreground. If token is non-empty, all /api and /ws requests must present it +// (via the "token" query parameter, a "mdtoken" cookie, or an +// "Authorization: Bearer" header). Call Start in a goroutine from main. +func Start(addr, token string, state *app.State, d *adb.ADB) error { state.Watcher.OnChange = func(ev watch.ChangeEvent) { wswatch.Broadcast(wswatch.Event{ Addr: fmt.Sprintf("0x%x", ev.Addr), @@ -42,64 +48,147 @@ func Start(addr string, state *app.State, d *adb.ADB) error { wswatch.Register(ws) })) - mux.HandleFunc("/api/status", h.status) + mux.HandleFunc("/api/status", get(h.status)) // Device - mux.HandleFunc("/api/device/list", h.deviceList) - mux.HandleFunc("/api/device/select", h.deviceSelect) - mux.HandleFunc("/api/device/connect-wifi", h.deviceConnectWifi) - mux.HandleFunc("/api/device/disconnect-wifi", h.deviceDisconnectWifi) + mux.HandleFunc("/api/device/list", get(h.deviceList)) + mux.HandleFunc("/api/device/select", post(h.deviceSelect)) + mux.HandleFunc("/api/device/connect-wifi", post(h.deviceConnectWifi)) + mux.HandleFunc("/api/device/disconnect-wifi", post(h.deviceDisconnectWifi)) // Process - mux.HandleFunc("/api/process/list", h.processList) - mux.HandleFunc("/api/process/search", h.processSearch) - mux.HandleFunc("/api/process/attach", h.processAttach) - mux.HandleFunc("/api/process/detach", h.processDetach) - mux.HandleFunc("/api/process/stop", h.processStop) - mux.HandleFunc("/api/process/continue", h.processContinue) + mux.HandleFunc("/api/process/list", get(h.processList)) + mux.HandleFunc("/api/process/search", post(h.processSearch)) + mux.HandleFunc("/api/process/attach", post(h.processAttach)) + mux.HandleFunc("/api/process/detach", post(h.processDetach)) + mux.HandleFunc("/api/process/stop", post(h.processStop)) + mux.HandleFunc("/api/process/continue", post(h.processContinue)) // Maps - mux.HandleFunc("/api/maps", h.mapsList) + mux.HandleFunc("/api/maps", get(h.mapsList)) // Search - mux.HandleFunc("/api/search/value", h.searchValue) - mux.HandleFunc("/api/search/pattern", h.searchPattern) - mux.HandleFunc("/api/search/string", h.searchString) - mux.HandleFunc("/api/search/filter", h.searchFilter) - mux.HandleFunc("/api/search/candidates", h.searchCandidates) - mux.HandleFunc("/api/search/reset", h.searchReset) + mux.HandleFunc("/api/search/value", post(h.searchValue)) + mux.HandleFunc("/api/search/pattern", post(h.searchPattern)) + mux.HandleFunc("/api/search/string", post(h.searchString)) + mux.HandleFunc("/api/search/filter", post(h.searchFilter)) + mux.HandleFunc("/api/search/candidates", get(h.searchCandidates)) + mux.HandleFunc("/api/search/reset", post(h.searchReset)) // Pointer scan - mux.HandleFunc("/api/pointer/scan", h.pointerScan) - mux.HandleFunc("/api/pointer/resolve", h.pointerResolve) + mux.HandleFunc("/api/pointer/scan", post(h.pointerScan)) + mux.HandleFunc("/api/pointer/resolve", post(h.pointerResolve)) // Memory - mux.HandleFunc("/api/memory/modify", h.memoryModify) - mux.HandleFunc("/api/memory/undo", h.memoryUndo) - mux.HandleFunc("/api/memory/freeze", h.memoryFreeze) - mux.HandleFunc("/api/memory/freeze-interval", h.freezeSetInterval) - mux.HandleFunc("/api/memory/freeze-all", h.memoryFreezeAll) - mux.HandleFunc("/api/memory/unfreeze", h.memoryUnfreeze) - mux.HandleFunc("/api/memory/frozen", h.memoryFrozen) - mux.HandleFunc("/api/memory/hexdump", h.memoryHexdump) + mux.HandleFunc("/api/memory/modify", post(h.memoryModify)) + mux.HandleFunc("/api/memory/undo", post(h.memoryUndo)) + mux.HandleFunc("/api/memory/freeze", post(h.memoryFreeze)) + mux.HandleFunc("/api/memory/freeze-interval", post(h.freezeSetInterval)) + mux.HandleFunc("/api/memory/freeze-all", post(h.memoryFreezeAll)) + mux.HandleFunc("/api/memory/unfreeze", post(h.memoryUnfreeze)) + mux.HandleFunc("/api/memory/frozen", get(h.memoryFrozen)) + mux.HandleFunc("/api/memory/hexdump", get(h.memoryHexdump)) // Snapshot - mux.HandleFunc("/api/snapshot/take", h.snapshotTake) - mux.HandleFunc("/api/snapshot/diff", h.snapshotDiff) + mux.HandleFunc("/api/snapshot/take", post(h.snapshotTake)) + mux.HandleFunc("/api/snapshot/diff", post(h.snapshotDiff)) // Bookmarks - mux.HandleFunc("/api/bookmark/list", h.bookmarkList) - mux.HandleFunc("/api/bookmark/add", h.bookmarkAdd) - mux.HandleFunc("/api/bookmark/remove", h.bookmarkRemove) - mux.HandleFunc("/api/bookmark/modify-all", h.bookmarkModifyAll) + mux.HandleFunc("/api/bookmark/list", get(h.bookmarkList)) + mux.HandleFunc("/api/bookmark/add", post(h.bookmarkAdd)) + mux.HandleFunc("/api/bookmark/remove", post(h.bookmarkRemove)) + mux.HandleFunc("/api/bookmark/modify-all", post(h.bookmarkModifyAll)) // Import - mux.HandleFunc("/api/import/ct", h.importCT) + mux.HandleFunc("/api/import/ct", post(h.importCT)) // Session - mux.HandleFunc("/api/session/save", h.sessionSave) - mux.HandleFunc("/api/session/load", h.sessionLoad) + mux.HandleFunc("/api/session/save", post(h.sessionSave)) + mux.HandleFunc("/api/session/load", post(h.sessionLoad)) + + srv := &http.Server{ + Addr: addr, + Handler: secure(token, mux), + ReadHeaderTimeout: 15 * time.Second, + WriteTimeout: 120 * time.Second, + IdleTimeout: 120 * time.Second, + } + + fmt.Printf("Web UI: %s\n", DisplayURL(addr)) + if token != "" { + fmt.Printf("Auth token required — open %s/?token=\n", DisplayURL(addr)) + } + return srv.ListenAndServe() +} - fmt.Printf("Web UI: http://localhost%s\n", addr) - return http.ListenAndServe(addr, mux) +// get wraps a handler so it only responds to GET (and HEAD). +func get(fn http.HandlerFunc) http.HandlerFunc { + return method(http.MethodGet, fn) +} + +// post wraps a handler so it only responds to POST. +func post(fn http.HandlerFunc) http.HandlerFunc { + return method(http.MethodPost, fn) +} + +func method(verb string, fn http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + allowed := r.Method == verb || (verb == http.MethodGet && r.Method == http.MethodHead) + if !allowed { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + fn(w, r) + } +} + +// secure caps the request body size and, when a token is configured, enforces +// it on /api and /ws paths. Presenting ?token= sets a cookie so the +// browser UI keeps working after the first navigation. +func secure(token string, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes) + + if token == "" { + next.ServeHTTP(w, r) + return + } + + if q := r.URL.Query().Get("token"); q != "" { + http.SetCookie(w, &http.Cookie{Name: "mdtoken", Value: q, Path: "/", HttpOnly: true}) + } + + protected := strings.HasPrefix(r.URL.Path, "/api/") || strings.HasPrefix(r.URL.Path, "/ws/") + if protected && !tokenOK(r, token) { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + next.ServeHTTP(w, r) + }) +} + +func tokenOK(r *http.Request, token string) bool { + if r.URL.Query().Get("token") == token { + return true + } + if c, err := r.Cookie("mdtoken"); err == nil && c.Value == token { + return true + } + if h := r.Header.Get("Authorization"); strings.TrimPrefix(h, "Bearer ") == token { + return true + } + return false +} + +// DisplayURL renders a browser-friendly URL from a listen address, turning a +// bare ":8080" or "0.0.0.0:8080" into a localhost URL. +func DisplayURL(addr string) string { + host, port, found := strings.Cut(addr, ":") + if !found { + return "http://" + addr + } + if host == "" || host == "0.0.0.0" { + host = "localhost" + } + return fmt.Sprintf("http://%s:%s", host, port) } diff --git a/internal/server/wswatch/wswatch.go b/internal/server/wswatch/wswatch.go index 0a202f2..45738d3 100644 --- a/internal/server/wswatch/wswatch.go +++ b/internal/server/wswatch/wswatch.go @@ -39,15 +39,34 @@ func Register(ws *websocket.Conn) { mu.Unlock() } -// Broadcast sends an Event to all connected WebSocket clients. +// Broadcast sends an Event to all connected WebSocket clients. Writes happen +// outside the lock so one slow client can't stall the hub, and clients whose +// write fails are dropped. func Broadcast(e Event) { data, err := json.Marshal(e) if err != nil { return } + mu.Lock() - defer mu.Unlock() + snapshot := make([]*websocket.Conn, 0, len(clients)) for ws := range clients { - _, _ = ws.Write(data) + snapshot = append(snapshot, ws) + } + mu.Unlock() + + var dead []*websocket.Conn + for _, ws := range snapshot { + if _, err := ws.Write(data); err != nil { + dead = append(dead, ws) + } + } + + if len(dead) > 0 { + mu.Lock() + for _, ws := range dead { + delete(clients, ws) + } + mu.Unlock() } } diff --git a/main.go b/main.go index 3c870cc..fba3cf1 100644 --- a/main.go +++ b/main.go @@ -1,32 +1,42 @@ package main import ( + "flag" "fmt" "os" + "strings" - "memodroid/internal/app" - "memodroid/internal/cli" - "memodroid/internal/driver/adb" - "memodroid/internal/memory/watch" - "memodroid/internal/server" + "memdroid/internal/app" + "memdroid/internal/cli" + "memdroid/internal/driver/adb" + "memdroid/internal/memory/watch" + "memdroid/internal/server" ) -const defaultServerAddr = ":8080" - func main() { + addr := flag.String("addr", "127.0.0.1:8080", "HTTP listen address for the Web UI/API") + token := flag.String("token", os.Getenv("MEMDROID_TOKEN"), "require this auth token on /api and /ws (empty = no auth)") + flag.Parse() + d := adb.New() autoSelectDevice(d) st := app.NewState(d) installWatchHandlers(st) + if !isLoopback(*addr) && *token == "" { + _, _ = fmt.Fprintf(os.Stderr, + "WARNING: binding %s exposes root memory access to the network with no auth. Use -addr 127.0.0.1:PORT or set -token.\n", + *addr) + } + go func() { - if err := server.Start(defaultServerAddr, st, d); err != nil { + if err := server.Start(*addr, *token, st, d); err != nil { _, _ = fmt.Fprintf(os.Stderr, "HTTP server error: %v\n", err) } }() - cli.ServerAddr = defaultServerAddr + cli.ServerURL = server.DisplayURL(*addr) cli.Run(st, d) } @@ -59,3 +69,11 @@ func installWatchHandlers(st *app.State) { fmt.Printf("[Alert] 0x%x: condition=%s value=%s action=%s\n", ev.Addr, ev.Condition, ev.Value, action) } } + +func isLoopback(addr string) bool { + host, _, found := strings.Cut(addr, ":") + if !found { + host = addr + } + return host == "127.0.0.1" || host == "localhost" || host == "::1" +}