Add fake-memory-reader tests for the scanner/poller
process.go's boundary functions (readMemory, findModuleBase, productVersion, findProcessID, openProcess, closeProcessHandle) become swappable package variables, same pattern as testExeDir in totals.go. process_test.go builds a fakeProcess (an in-memory buffer addressed like real process memory) to exercise signature scanning, scanModule's chunk-overlap logic, PlayerIns confirmation by memory read (the path Linux always takes), the save-slot read, the double-read name confirmation rule, and rejection of raw-pointer-as-UTF16 garbage. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2ba312a833
commit
66aba2fff2
3 files changed
+321
-25
No files matched your search
@@ -122,11 +122,16 @@ local (el caso de una PC con el OBS en Linux mientras se juega en otra),
|
||||
3s sin nunca llegar a `openProcess` — `setcap`/`ptrace_scope` no entran
|
||||
en juego para nada en ese caso.
|
||||
|
||||
**Pendiente, no parte de este cambio:** testear el escáner/poller con un
|
||||
lector de memoria falso. El split ya lo habilita, pero escribir esos
|
||||
tests (siguiendo el patrón de variable de paquete intercambiable que ya
|
||||
usa `testExeDir` en `totals.go`, no una interfaz nueva) queda para
|
||||
después — ver Pendientes.
|
||||
El split habilita testear el escáner/poller sin una PC con el juego
|
||||
abierto: `readMemoryFn`, `findModuleBaseFn` y `productVersionFn`
|
||||
(`process.go`) son variables de paquete que por defecto apuntan a las
|
||||
implementaciones de plataforma, mismo patrón que `testExeDir` en
|
||||
`totals.go`. `process_test.go` las pisa con un `fakeProcess` — un buffer
|
||||
en memoria direccionado como memoria de proceso real — y ejercita el
|
||||
escaneo de las tres firmas, el overlap de `scanModule` entre chunks de 1
|
||||
MiB, la confirmación de `PlayerIns` por lectura (el camino que toma
|
||||
Linux siempre, al no haber versión), el slot de guardado, y la regla de
|
||||
doble lectura para confirmar el nombre del personaje.
|
||||
|
||||
## Offsets de memoria
|
||||
|
||||
@@ -272,10 +277,5 @@ No son preferencias de estilo. Cada una costó un bug en producción.
|
||||
está en inglés de punta a punta, ver "Idioma del código" arriba.
|
||||
- La identificación por nombre (respaldo cuando no se lee el slot) mezcla
|
||||
personajes homónimos. Documentado, no resuelto.
|
||||
- Testear el escáner/poller (`process.go`) con un lector de memoria
|
||||
falso. El split multiplataforma (ver "Multiplataforma" arriba) ya lo
|
||||
habilita — falta escribir los tests en sí, con una variable de paquete
|
||||
intercambiable por función de frontera (mismo patrón que `testExeDir`
|
||||
en `totals.go`), no una interfaz nueva.
|
||||
- El README debería mencionar que meter todo adentro de una VPN sigue
|
||||
siendo una opción perfectamente válida, TLS+pinning aparte.
|
||||
+28
-15
@@ -31,6 +31,19 @@ const processName = "eldenring.exe"
|
||||
// process_linux.go).
|
||||
type procHandle uintptr
|
||||
|
||||
// Boundary functions as package variables, defaulting to the platform
|
||||
// implementation compiled into process_windows.go/process_linux.go.
|
||||
// Tests (process_test.go) swap these for a fake in-memory reader — same
|
||||
// pattern as testExeDir in totals.go, not a new interface.
|
||||
var (
|
||||
findProcessIDFn = findProcessID
|
||||
openProcessFn = openProcess
|
||||
closeProcessHandleFn = closeProcessHandle
|
||||
readMemoryFn = readMemory
|
||||
findModuleBaseFn = findModuleBase
|
||||
productVersionFn = productVersion
|
||||
)
|
||||
|
||||
// ------------------------- signature scans -------------------------
|
||||
//
|
||||
// Two signatures, both the same shape: a 7-byte "mov reg,[rip+disp32]"
|
||||
@@ -111,7 +124,7 @@ func scanModule(h procHandle, base uintptr, size uint32, patterns [][]patByte) [
|
||||
if rem := int(size - pos); readSize > rem {
|
||||
readSize = rem
|
||||
}
|
||||
buf, ok := readMemory(h, base+uintptr(pos), readSize)
|
||||
buf, ok := readMemoryFn(h, base+uintptr(pos), readSize)
|
||||
if ok {
|
||||
for i := 0; i < len(buf); i++ {
|
||||
for p := range patterns {
|
||||
@@ -140,7 +153,7 @@ func ripSlot(h procHandle, matchAddr uintptr) (uintptr, error) {
|
||||
return 0, fmt.Errorf("pattern not found")
|
||||
}
|
||||
codeLocation := matchAddr + 3 // the first 3 bytes are the opcode
|
||||
dispBytes, ok := readMemory(h, codeLocation, 4)
|
||||
dispBytes, ok := readMemoryFn(h, codeLocation, 4)
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("couldn't read the RIP-relative displacement")
|
||||
}
|
||||
@@ -178,12 +191,12 @@ type gamePointers struct {
|
||||
func resolvePointers(h procHandle, pid uint32) (gamePointers, error) {
|
||||
var gp gamePointers
|
||||
|
||||
base, size, exePath, err := findModuleBase(pid, processName)
|
||||
base, size, exePath, err := findModuleBaseFn(pid, processName)
|
||||
if err != nil {
|
||||
return gp, err
|
||||
}
|
||||
|
||||
major, minor, label, okVer := productVersion(exePath)
|
||||
major, minor, label, okVer := productVersionFn(exePath)
|
||||
gp.playerInsTried = playerInsCandidates(major, minor, okVer)
|
||||
gp.playerInsOffset = gp.playerInsTried[0]
|
||||
gp.versionLabel = label
|
||||
@@ -216,7 +229,7 @@ func derefPointer(h procHandle, slot uintptr) (uintptr, bool) {
|
||||
if slot == 0 {
|
||||
return 0, false
|
||||
}
|
||||
buf, ok := readMemory(h, slot, 8)
|
||||
buf, ok := readMemoryFn(h, slot, 8)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
@@ -257,7 +270,7 @@ func readCharName(h procHandle, gameDataMan uintptr, c nameCandidate) (string, b
|
||||
}
|
||||
base = p
|
||||
}
|
||||
buf, ok := readMemory(h, base+c.nameOffset, charNameMaxChars*2)
|
||||
buf, ok := readMemoryFn(h, base+c.nameOffset, charNameMaxChars*2)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
@@ -316,7 +329,7 @@ func readSaveSlot(h procHandle, gp gamePointers) int {
|
||||
if !ok || gameMan == 0 {
|
||||
return -1
|
||||
}
|
||||
buf, ok := readMemory(h, gameMan+saveSlotOffset, 1)
|
||||
buf, ok := readMemoryFn(h, gameMan+saveSlotOffset, 1)
|
||||
if !ok {
|
||||
return -1
|
||||
}
|
||||
@@ -365,7 +378,7 @@ func isPlayerLoaded(h procHandle, gp *gamePointers) (loaded bool, known bool) {
|
||||
}
|
||||
// A real pointer points at mapped memory; a garbage one almost
|
||||
// never survives this read.
|
||||
if _, ok := readMemory(h, playerIns, 8); !ok {
|
||||
if _, ok := readMemoryFn(h, playerIns, 8); !ok {
|
||||
continue
|
||||
}
|
||||
gp.playerInsOffset = cand
|
||||
@@ -436,7 +449,7 @@ func pollLoop() {
|
||||
|
||||
closeHandle := func() {
|
||||
if handle != 0 {
|
||||
closeProcessHandle(handle)
|
||||
closeProcessHandleFn(handle)
|
||||
handle = 0
|
||||
}
|
||||
pid = 0
|
||||
@@ -449,13 +462,13 @@ func pollLoop() {
|
||||
|
||||
for {
|
||||
if handle == 0 {
|
||||
newPid, err := findProcessID(processName)
|
||||
newPid, err := findProcessIDFn(processName)
|
||||
if err != nil {
|
||||
st.setDisconnected("waiting for eldenring.exe")
|
||||
time.Sleep(3 * time.Second)
|
||||
continue
|
||||
}
|
||||
h, err := openProcess(newPid)
|
||||
h, err := openProcessFn(newPid)
|
||||
if err != nil {
|
||||
st.setDisconnected("couldn't open the process (permissions?): " + err.Error())
|
||||
time.Sleep(3 * time.Second)
|
||||
@@ -472,7 +485,7 @@ func pollLoop() {
|
||||
st.setDisconnected(err.Error())
|
||||
time.Sleep(2 * time.Second)
|
||||
// if the process died, release the handle to retry from scratch
|
||||
if _, ferr := findProcessID(processName); ferr != nil {
|
||||
if _, ferr := findProcessIDFn(processName); ferr != nil {
|
||||
closeHandle()
|
||||
}
|
||||
continue
|
||||
@@ -510,7 +523,7 @@ func pollLoop() {
|
||||
// and our detection was lying. Unlike a timeout, this can't
|
||||
// fire just from leaving the game sitting at the menu a while.
|
||||
if gdm, ok := derefPointer(handle, gp.gameDataManSlot); ok && gdm != 0 {
|
||||
if buf, ok := readMemory(handle, gdm+0x94, 4); ok {
|
||||
if buf, ok := readMemoryFn(handle, gdm+0x94, 4); ok {
|
||||
raw := int32(binary.LittleEndian.Uint32(buf))
|
||||
if raw >= 0 && raw < 1_000_000 {
|
||||
if haveUnloadedRaw {
|
||||
@@ -562,8 +575,8 @@ func pollLoop() {
|
||||
continue
|
||||
}
|
||||
|
||||
deathsBuf, ok1 := readMemory(handle, gameDataMan+0x94, 4)
|
||||
bossBuf, ok2 := readMemory(handle, gameDataMan+0xC0, 1)
|
||||
deathsBuf, ok1 := readMemoryFn(handle, gameDataMan+0x94, 4)
|
||||
bossBuf, ok2 := readMemoryFn(handle, gameDataMan+0xC0, 1)
|
||||
if !ok1 {
|
||||
st.setDisconnected("lost the memory reading (the game closed or restarted)")
|
||||
closeHandle()
|
||||
|
||||
+283
@@ -0,0 +1,283 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"testing"
|
||||
"unicode/utf16"
|
||||
)
|
||||
|
||||
// fakeProcess is an in-memory stand-in for eldenring.exe's address space: a
|
||||
// single flat buffer addressed exactly like real process memory, letting
|
||||
// the scanner/poller logic in process.go run without a PC with the game
|
||||
// open. It's wired in through readMemoryFn/findModuleBaseFn/productVersionFn
|
||||
// (see the var block at the top of process.go) — the same swappable-package-
|
||||
// variable pattern testExeDir uses in totals.go, not a new interface.
|
||||
type fakeProcess struct {
|
||||
base uintptr
|
||||
mem []byte
|
||||
}
|
||||
|
||||
func newFakeProcess(base uintptr, size int) *fakeProcess {
|
||||
return &fakeProcess{base: base, mem: make([]byte, size)}
|
||||
}
|
||||
|
||||
// read mirrors readMemory's contract: a read that falls even partially
|
||||
// outside mapped memory fails, same as an unmapped page would on a real
|
||||
// process.
|
||||
func (f *fakeProcess) read(_ procHandle, addr uintptr, size int) ([]byte, bool) {
|
||||
if addr < f.base {
|
||||
return nil, false
|
||||
}
|
||||
off := int(addr - f.base)
|
||||
if off+size > len(f.mem) {
|
||||
return nil, false
|
||||
}
|
||||
out := make([]byte, size)
|
||||
copy(out, f.mem[off:off+size])
|
||||
return out, true
|
||||
}
|
||||
|
||||
func (f *fakeProcess) putBytes(addr uintptr, b []byte) {
|
||||
off := int(addr - f.base)
|
||||
copy(f.mem[off:], b)
|
||||
}
|
||||
|
||||
func (f *fakeProcess) putUint64(addr uintptr, v uint64) {
|
||||
var b [8]byte
|
||||
binary.LittleEndian.PutUint64(b[:], v)
|
||||
f.putBytes(addr, b[:])
|
||||
}
|
||||
|
||||
func (f *fakeProcess) putUint32(addr uintptr, v uint32) {
|
||||
var b [4]byte
|
||||
binary.LittleEndian.PutUint32(b[:], v)
|
||||
f.putBytes(addr, b[:])
|
||||
}
|
||||
|
||||
func (f *fakeProcess) putByte(addr uintptr, v byte) {
|
||||
f.mem[int(addr-f.base)] = v
|
||||
}
|
||||
|
||||
func (f *fakeProcess) putUTF16(addr uintptr, s string) {
|
||||
for i, u := range utf16.Encode([]rune(s)) {
|
||||
var b [2]byte
|
||||
binary.LittleEndian.PutUint16(b[:], u)
|
||||
f.putBytes(addr+uintptr(i*2), b[:])
|
||||
}
|
||||
}
|
||||
|
||||
// writePattern lays pat's fixed bytes down at addr (wildcard bytes left as
|
||||
// zero, since matchAt never looks at them).
|
||||
func writePattern(f *fakeProcess, addr uintptr, pat []patByte) {
|
||||
buf := make([]byte, len(pat))
|
||||
for i, p := range pat {
|
||||
if !p.wildcard {
|
||||
buf[i] = p.val
|
||||
}
|
||||
}
|
||||
f.putBytes(addr, buf)
|
||||
}
|
||||
|
||||
// pointRipSlot fills in the disp32 of a "mov reg,[rip+disp32]" instruction
|
||||
// at addr so it resolves (via ripSlot) to slotAddr — the same encoding a
|
||||
// real compiled game binary uses.
|
||||
func pointRipSlot(f *fakeProcess, addr, slotAddr uintptr) {
|
||||
disp := int32(int64(slotAddr) - int64(addr+7))
|
||||
f.putUint32(addr+3, uint32(disp))
|
||||
}
|
||||
|
||||
// TestScanModuleFindsPatternStraddlingChunkBoundary is a regression test for
|
||||
// scanModule's chunk/overlap logic: a pattern whose bytes straddle the 1 MiB
|
||||
// chunk boundary must still be found. Without the overlap, half the pattern
|
||||
// would land in one chunk read and half in the next, and matchAt would never
|
||||
// see it whole.
|
||||
func TestScanModuleFindsPatternStraddlingChunkBoundary(t *testing.T) {
|
||||
const chunk = 1 << 20
|
||||
base := uintptr(0x1_4000_0000)
|
||||
size := chunk + 4096
|
||||
|
||||
fp := newFakeProcess(base, size)
|
||||
patAddr := base + chunk - 5 // starts 5 bytes before the boundary, ends well after it
|
||||
writePattern(fp, patAddr, gameDataManPattern)
|
||||
pointRipSlot(fp, patAddr, base+uintptr(size-16))
|
||||
|
||||
orig := readMemoryFn
|
||||
readMemoryFn = fp.read
|
||||
defer func() { readMemoryFn = orig }()
|
||||
|
||||
found := scanModule(procHandle(1), base, uint32(size), [][]patByte{gameDataManPattern})
|
||||
if found[0] != patAddr {
|
||||
t.Fatalf("pattern straddling the chunk boundary not found: got 0x%X, want 0x%X", found[0], patAddr)
|
||||
}
|
||||
}
|
||||
|
||||
// eldenRingLayout builds a fake process with all three signatures and the
|
||||
// object graph resolvePointers/pollLoop walk to reach the death counter,
|
||||
// the loaded-character check, the save slot, and the character name. It
|
||||
// mirrors CLAUDE.md's "Offsets de memoria" table.
|
||||
type eldenRingLayout struct {
|
||||
fp *fakeProcess
|
||||
|
||||
gameDataManSlot uintptr
|
||||
gameDataMan uintptr
|
||||
worldChrManSlot uintptr
|
||||
worldChrMan uintptr
|
||||
gameManSlot uintptr
|
||||
gameMan uintptr
|
||||
playerIns uintptr
|
||||
playerGameData uintptr
|
||||
}
|
||||
|
||||
func newEldenRingLayout() *eldenRingLayout {
|
||||
base := uintptr(0x1_4000_0000)
|
||||
fp := newFakeProcess(base, 0x40000)
|
||||
l := &eldenRingLayout{
|
||||
fp: fp,
|
||||
gameDataManSlot: base + 0x2000,
|
||||
gameDataMan: base + 0x3000,
|
||||
worldChrManSlot: base + 0x6000,
|
||||
worldChrMan: base + 0x7000,
|
||||
gameManSlot: base + 0x9000,
|
||||
gameMan: base + 0xA000,
|
||||
playerIns: base + 0x30000,
|
||||
playerGameData: base + 0x4000,
|
||||
}
|
||||
|
||||
writePattern(fp, base+0x1000, gameDataManPattern)
|
||||
pointRipSlot(fp, base+0x1000, l.gameDataManSlot)
|
||||
fp.putUint64(l.gameDataManSlot, uint64(l.gameDataMan))
|
||||
fp.putUint64(l.gameDataMan+0x08, uint64(l.playerGameData)) // PlayerGameData, first name candidate
|
||||
fp.putUTF16(l.playerGameData+0x9C, "Aria")
|
||||
|
||||
writePattern(fp, base+0x5000, worldChrManPattern)
|
||||
pointRipSlot(fp, base+0x5000, l.worldChrManSlot)
|
||||
fp.putUint64(l.worldChrManSlot, uint64(l.worldChrMan))
|
||||
fp.putUint64(l.worldChrMan+0x1E508, uint64(l.playerIns)) // current PlayerIns offset
|
||||
|
||||
writePattern(fp, base+0x8000, gameManPattern)
|
||||
pointRipSlot(fp, base+0x8000, l.gameManSlot)
|
||||
fp.putUint64(l.gameManSlot, uint64(l.gameMan))
|
||||
fp.putByte(l.gameMan+saveSlotOffset, 3)
|
||||
|
||||
return l
|
||||
}
|
||||
|
||||
// withFakeGame swaps the boundary functions the poller uses to a fake
|
||||
// process built from an eldenRingLayout, as if productVersion couldn't
|
||||
// report anything (like on Linux, see process_linux.go), forcing PlayerIns
|
||||
// resolution down the in-memory-confirmation path instead of the version
|
||||
// hunch.
|
||||
func withFakeGame(t *testing.T) *eldenRingLayout {
|
||||
t.Helper()
|
||||
l := newEldenRingLayout()
|
||||
|
||||
origRead, origBase, origVersion := readMemoryFn, findModuleBaseFn, productVersionFn
|
||||
readMemoryFn = l.fp.read
|
||||
findModuleBaseFn = func(pid uint32, name string) (uintptr, uint32, string, error) {
|
||||
return l.fp.base, uint32(len(l.fp.mem)), "Z:\\fake\\eldenring.exe", nil
|
||||
}
|
||||
productVersionFn = func(path string) (uint16, uint16, string, bool) {
|
||||
return 0, 0, "", false
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
readMemoryFn = origRead
|
||||
findModuleBaseFn = origBase
|
||||
productVersionFn = origVersion
|
||||
})
|
||||
return l
|
||||
}
|
||||
|
||||
func TestResolvePointersFindsAllThreeSignatures(t *testing.T) {
|
||||
l := withFakeGame(t)
|
||||
|
||||
gp, err := resolvePointers(procHandle(1), 1234)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gp.gameDataManSlot != l.gameDataManSlot {
|
||||
t.Errorf("GameDataMan slot = 0x%X, want 0x%X", gp.gameDataManSlot, l.gameDataManSlot)
|
||||
}
|
||||
if gp.worldChrManSlot != l.worldChrManSlot {
|
||||
t.Errorf("WorldChrMan slot = 0x%X, want 0x%X", gp.worldChrManSlot, l.worldChrManSlot)
|
||||
}
|
||||
if gp.gameManSlot != l.gameManSlot {
|
||||
t.Errorf("GameMan slot = 0x%X, want 0x%X", gp.gameManSlot, l.gameManSlot)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsPlayerLoadedConfirmsOffsetByReadingMemory exercises the Linux path
|
||||
// (productVersion always ok=false, see process_linux.go): with no version
|
||||
// hunch, isPlayerLoaded must fall back to trying each known PlayerIns
|
||||
// offset and trust whichever one points at genuinely readable memory.
|
||||
func TestIsPlayerLoadedConfirmsOffsetByReadingMemory(t *testing.T) {
|
||||
withFakeGame(t)
|
||||
|
||||
gp, err := resolvePointers(procHandle(1), 1234)
|
||||
if err != nil {
|
||||
t.Fatalf("resolvePointers: %v", err)
|
||||
}
|
||||
|
||||
loaded, known := isPlayerLoaded(procHandle(1), &gp)
|
||||
if !known || !loaded {
|
||||
t.Fatalf("isPlayerLoaded = (%v, %v), want (true, true)", loaded, known)
|
||||
}
|
||||
if !gp.offsetConfirmed || gp.playerInsOffset != 0x1E508 {
|
||||
t.Fatalf("PlayerIns offset not confirmed at 0x1E508: confirmed=%v offset=0x%X", gp.offsetConfirmed, gp.playerInsOffset)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadSaveSlotReadsGameManByte(t *testing.T) {
|
||||
withFakeGame(t)
|
||||
|
||||
gp, err := resolvePointers(procHandle(1), 1234)
|
||||
if err != nil {
|
||||
t.Fatalf("resolvePointers: %v", err)
|
||||
}
|
||||
if got := readSaveSlot(procHandle(1), gp); got != 3 {
|
||||
t.Fatalf("readSaveSlot = %d, want 3", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveCharNameRequiresTwoMatchingReadsBeforeConfirming locks in the
|
||||
// double-read confirmation rule (process.go, resolveCharName): a candidate
|
||||
// only gets trusted once it reads the SAME text twice in a row.
|
||||
func TestResolveCharNameRequiresTwoMatchingReadsBeforeConfirming(t *testing.T) {
|
||||
l := withFakeGame(t)
|
||||
|
||||
gp, err := resolvePointers(procHandle(1), 1234)
|
||||
if err != nil {
|
||||
t.Fatalf("resolvePointers: %v", err)
|
||||
}
|
||||
|
||||
name, ok := resolveCharName(procHandle(1), l.gameDataMan, &gp)
|
||||
if ok {
|
||||
t.Fatalf("first read shouldn't confirm yet, got (%q, true)", name)
|
||||
}
|
||||
if gp.nameConfirmed {
|
||||
t.Fatal("nameConfirmed set after just one read")
|
||||
}
|
||||
|
||||
name, ok = resolveCharName(procHandle(1), l.gameDataMan, &gp)
|
||||
if !ok || name != "Aria" {
|
||||
t.Fatalf("second matching read should confirm \"Aria\", got (%q, %v)", name, ok)
|
||||
}
|
||||
if !gp.nameConfirmed {
|
||||
t.Fatal("nameConfirmed should be true after two matching reads")
|
||||
}
|
||||
}
|
||||
|
||||
// TestReadCharNameRejectsRawPointerBytes is the scenario CLAUDE.md's
|
||||
// "Offsets de memoria" section calls out by name: decoding a raw 64-bit
|
||||
// pointer as if it were UTF-16 text produces garbage that mixes unrelated
|
||||
// script families (here Greek-ish + Han-ish code points) and must be
|
||||
// rejected, not shown as a character name.
|
||||
func TestReadCharNameRejectsRawPointerBytes(t *testing.T) {
|
||||
l := withFakeGame(t)
|
||||
// Overwrite the name text with what a raw pointer looks like reinterpreted
|
||||
// as UTF-16 code units instead of a real name.
|
||||
l.fp.putUint64(l.playerGameData+0x9C, 0x00007FF6_ABCDEF12)
|
||||
|
||||
if _, ok := readCharName(procHandle(1), l.gameDataMan, nameCandidates[0]); ok {
|
||||
t.Fatal("readCharName accepted raw pointer bytes decoded as UTF-16 as a name")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user