Files
bd2/go/internal/fixture/fixture.go
T
2026-09-21 09:53:46 +08:00

703 lines
23 KiB
Go

// Package fixture reads captured BD2 protocol messages without duplicating the
// large capture in the source tree. It is intended for local-server bootstrap
// fixtures and protocol tests, not as a persistence format.
package fixture
import (
"bytes"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"bd2server/internal/cryptox"
)
const CaptureVersion = "2.34.13"
// NamedFixture names the small, stable bootstrap response set. Files remain
// in capture/<version>/<run>/bodies and are loaded on demand.
type NamedFixture struct {
Name string
Path string
File string
Key KeyKind
}
type KeyKind uint8
const (
Plain KeyKind = iota
FixedLogin
Session
)
var Bootstrap = []NamedFixture{
{Name: "maintenance", Path: "/MaintenanceInfo", File: "00010_resp_MaintenanceInfo.bin", Key: Plain},
{Name: "server_info", Path: "/ServerInfo", File: "00012_resp_ServerInfo.bin", Key: Plain},
{Name: "login", Path: "/LoginUser", File: "00016_resp_LoginUser.bin", Key: FixedLogin},
{Name: "notice", Path: "/NoticeInfo", File: "00020_resp_NoticeInfo.bin", Key: Plain},
}
var (
ErrNotFound = errors.New("bd2 fixture: response not found")
ErrUnsafePath = errors.New("bd2 fixture: unsafe capture path")
ErrNoPayload = errors.New("bd2 fixture: response has no data payload")
ErrLengthMismatch = errors.New("bd2 fixture: response length does not match decoded payload")
)
// Capture points at a single timestamped capture directory, i.e.
// capture/2.34.13/20260920-003254.
type Capture struct {
Root string
}
// Set is the local-server integration view of one captured new-player run.
// It intentionally keeps responses on disk and only loads bodies on demand;
// this avoids copying the capture into binaries or maintaining a second large
// fixture tree. Load accepts the timestamped capture directory itself.
type Set struct {
*Capture
core map[string]NamedFixture
responses map[string][]RecordedResponse
}
// RecordedResponse has an unambiguous selector: Path plus RequestSequence.
// Sequence is the one-based request event number in bd2_dump.jsonl, rather
// than an ordinal response count. This prevents a state-changing endpoint
// from accidentally receiving a response captured for another request.
type RecordedResponse struct {
Path string
Method string
File string
RequestSequence int
ClientSequence uint64
}
// RecordedResponses lists the unambiguous capture selectors for path. The
// returned slice is detached from the fixture index and safe for callers to
// retain as a scripted bootstrap plan.
func (s *Set) RecordedResponses(path string) []RecordedResponse {
return append([]RecordedResponse(nil), s.responses[path]...)
}
// Load opens an on-disk capture and indexes its stable core endpoints.
func Load(captureDir string) (*Set, error) {
c, err := Open(captureDir)
if err != nil {
return nil, err
}
core := make(map[string]NamedFixture, len(Bootstrap))
for _, spec := range Bootstrap {
core[spec.Path] = spec
}
responses, err := indexResponses(c.Root)
if err != nil {
return nil, err
}
return &Set{Capture: c, core: core, responses: responses}, nil
}
type dumpRecord struct {
Event string `json:"ev"`
Game bool `json:"game"`
FlowID string `json:"flow_id"`
Method string `json:"method"`
Path string `json:"path"`
RawFile string `json:"raw_file"`
PB []string `json:"pb"`
}
type indexedRequest struct {
CaptureSequence int
ClientSequence uint64
}
func indexResponses(root string) (map[string][]RecordedResponse, error) {
data, err := os.ReadFile(filepath.Join(root, "bd2_dump.jsonl"))
if err != nil {
return nil, fmt.Errorf("read capture index: %w", err)
}
requests := make(map[string]indexedRequest)
responses := make(map[string][]RecordedResponse)
sequence := 0
for lineNo, line := range bytes.Split(data, []byte{'\n'}) {
if len(bytes.TrimSpace(line)) == 0 {
continue
}
var record dumpRecord
if err := json.Unmarshal(line, &record); err != nil {
return nil, fmt.Errorf("decode capture index line %d: %w", lineNo+1, err)
}
if !record.Game || record.FlowID == "" {
continue
}
if record.Event == "req" {
sequence++
requests[record.FlowID] = indexedRequest{CaptureSequence: sequence, ClientSequence: protobufSequence(record.PB)}
continue
}
if record.Event != "resp" || record.RawFile == "" {
continue
}
request, found := requests[record.FlowID]
if !found {
continue // capture can begin after an already-open HTTP flow
}
if err := safeName(filepath.Base(record.RawFile)); err != nil {
return nil, fmt.Errorf("capture index line %d: %w", lineNo+1, err)
}
responses[record.Path] = append(responses[record.Path], RecordedResponse{
Path: record.Path, Method: record.Method, File: filepath.Base(record.RawFile), RequestSequence: request.CaptureSequence, ClientSequence: request.ClientSequence,
})
}
return responses, nil
}
func protobufSequence(fields []string) uint64 {
const prefix = "#1 varint "
for _, field := range fields {
text := strings.TrimSpace(field)
if !strings.HasPrefix(text, prefix) {
continue
}
value, err := strconv.ParseUint(strings.TrimSpace(strings.TrimPrefix(text, prefix)), 10, 64)
if err == nil {
return value
}
}
return 0
}
// RecordedResponseForClientSequence resolves the captured response using the
// endpoint and the request message's protobuf seq field. A missing/zero seq
// never falls back to a path-only match.
func (s *Set) RecordedResponseForClientSequence(path string, clientSequence uint64) (RecordedResponse, error) {
if clientSequence == 0 {
return RecordedResponse{}, fmt.Errorf("%w: response %s has no client sequence", ErrNotFound, path)
}
for _, response := range s.responses[path] {
if response.ClientSequence == clientSequence {
return response, nil
}
}
return RecordedResponse{}, fmt.Errorf("%w: response %s at client sequence %d", ErrNotFound, path, clientSequence)
}
func Open(root string) (*Capture, error) {
info, err := os.Stat(root)
if err != nil {
return nil, fmt.Errorf("open capture: %w", err)
}
if !info.IsDir() {
return nil, fmt.Errorf("open capture: %s is not a directory", root)
}
if _, err := os.Stat(filepath.Join(root, "bodies")); err != nil {
return nil, fmt.Errorf("open capture bodies: %w", err)
}
return &Capture{Root: root}, nil
}
// Response is a raw captured HTTP body along with its recorded endpoint.
type Response struct {
Path string
File string
Body []byte
}
// BootstrapResponse loads one of the fixed bootstrap fixtures by symbolic name.
func (c *Capture) BootstrapResponse(name string) (Response, error) {
for _, spec := range Bootstrap {
if spec.Name == name {
return c.readResponse(spec.Path, spec.File)
}
}
return Response{}, fmt.Errorf("%w: bootstrap %q", ErrNotFound, name)
}
func (c *Capture) Maintenance() (Response, error) { return c.BootstrapResponse("maintenance") }
func (c *Capture) ServerInfo() (Response, error) { return c.BootstrapResponse("server_info") }
func (c *Capture) Login() (Response, error) { return c.BootstrapResponse("login") }
func (c *Capture) Notice() (Response, error) { return c.BootstrapResponse("notice") }
// CoreResponse returns the original JSON HTTP response for a bootstrap path.
// It is useful where a handler deliberately wants an exact captured response.
func (s *Set) CoreResponse(path string) ([]byte, error) {
spec, ok := s.core[path]
if !ok {
return nil, fmt.Errorf("%w: core path %q", ErrNotFound, path)
}
response, err := s.readResponse(spec.Path, spec.File)
if err != nil {
return nil, err
}
return append([]byte(nil), response.Body...), nil
}
// RecordedResponseAt retrieves one captured HTTP response only when both its
// endpoint path and original request sequence match. Passing an arbitrary
// ordinal is deliberately not supported; callers should record the sequence
// that their route/session policy maps to a fixture.
func (s *Set) RecordedResponseAt(path string, requestSequence int) ([]byte, RecordedResponse, error) {
for _, response := range s.responses[path] {
if response.RequestSequence != requestSequence {
continue
}
body, err := os.ReadFile(filepath.Join(s.Root, "bodies", response.File))
if err != nil {
return nil, RecordedResponse{}, fmt.Errorf("read recorded response %s: %w", response.File, err)
}
return body, response, nil
}
return nil, RecordedResponse{}, fmt.Errorf("%w: response %s at request sequence %d", ErrNotFound, path, requestSequence)
}
// RecordedPayloadAt decodes a selected captured response with the captured
// session key. LoginUser is always decoded with the fixed pre-login key.
func (s *Set) RecordedPayloadAt(path string, requestSequence int) (Payload, RecordedResponse, error) {
body, response, err := s.RecordedResponseAt(path, requestSequence)
if err != nil {
return Payload{}, RecordedResponse{}, err
}
key := []byte(nil)
if path == "/LoginUser" || path == "/JoinUser" {
key = cryptox.Key()
} else {
capturedKey, err := s.SessionKey()
if err != nil {
return Payload{}, RecordedResponse{}, err
}
key = []byte(capturedKey)
}
env, proto, encrypted, err := decodeEnvelope(body, key)
if err != nil {
return Payload{}, RecordedResponse{}, err
}
return Payload{Path: path, PacketCode: env.PacketCode, Proto: proto, Envelope: env, Encrypted: encrypted}, response, nil
}
// ReencryptRecordedAt is the safe replay helper for a selected response. It
// preserves unencrypted protocol responses, but re-encrypts every encrypted
// response payload with the supplied local-session key and fixes length.
func (s *Set) ReencryptRecordedAt(path string, requestSequence int, localSessionKey []byte) ([]byte, RecordedResponse, error) {
payload, response, err := s.RecordedPayloadAt(path, requestSequence)
if err != nil {
return nil, RecordedResponse{}, err
}
if !payload.Encrypted {
body, _, err := s.RecordedResponseAt(path, requestSequence)
return body, response, err
}
body, err := EncodePayload(payload, localSessionKey, true)
return body, response, err
}
// Payload is the decoded portion of a captured endpoint response. PacketCode
// and Proto are sufficient for a server to modify state and re-encrypt it.
type Payload struct {
Path string
PacketCode int
Proto []byte
Envelope Envelope
Encrypted bool
}
// CorePayload decodes a core fixture. LoginUser uses the fixed pre-login key;
// the remaining initial bootstrap bodies are direct base64 protobuf payloads.
func (s *Set) CorePayload(path string) (Payload, error) {
spec, ok := s.core[path]
if !ok {
return Payload{}, fmt.Errorf("%w: core path %q", ErrNotFound, path)
}
body, err := s.CoreResponse(path)
if err != nil {
return Payload{}, err
}
key := []byte(nil)
if spec.Key == FixedLogin {
key = cryptox.Key()
}
env, proto, encrypted, err := decodeEnvelope(body, key)
if err != nil {
return Payload{}, err
}
return Payload{Path: path, PacketCode: env.PacketCode, Proto: proto, Envelope: env, Encrypted: encrypted}, nil
}
// SessionKey returns the captured LoginUser user_key. It is intended only for
// offline fixture decoding; callers should replace it with a local test key
// once their account/session layer exists. This package never logs it.
func (s *Set) SessionKey() (string, error) {
payload, err := s.CorePayload("/LoginUser")
if err != nil {
return "", err
}
match := sessionKeyPattern.Find(payload.Proto)
if match == nil {
return "", errors.New("bd2 fixture: LoginUser payload does not contain user_key")
}
return string(match), nil
}
var sessionKeyPattern = regexp.MustCompile(`[0-9a-f]{32}`)
// ReencryptCore returns a core response whose payload has been encrypted with
// key. It is primarily for LoginUser replay after a handler substitutes a
// local user key in its protobuf. Callers who mutate protobuf should prefer
// EncodePayload below.
func (s *Set) ReencryptCore(path string, key []byte) ([]byte, error) {
payload, err := s.CorePayload(path)
if err != nil {
return nil, err
}
return EncodePayload(payload, key, true)
}
// EncodePayload serializes a server-owned payload with an accurate length.
func EncodePayload(payload Payload, key []byte, encrypted bool) ([]byte, error) {
return EncodeEnvelope(payload.Envelope, payload.Proto, key, encrypted)
}
// Batch loads one of the two recorded initial BatchRequest response bodies.
func (c *Capture) Batch(number int) (Response, error) {
switch number {
case 1:
return c.readResponse("/BatchRequest", "00041_resp_BatchRequest.bin")
case 2:
return c.readResponse("/BatchRequest", "00077_resp_BatchRequest.bin")
default:
return Response{}, fmt.Errorf("%w: batch %d", ErrNotFound, number)
}
}
// BatchResponse returns the captured raw BatchRequest response. For live
// replay prefer BatchForPaths, because capture response order is arbitrary.
func (s *Set) BatchResponse(index int) ([]byte, error) {
response, err := s.Batch(index)
if err != nil {
return nil, err
}
return append([]byte(nil), response.Body...), nil
}
// BatchForPaths returns the captured responses in exactly the request path
// order supplied by the client. It verifies both the captured request and
// response before selecting items, preventing accidental path drift.
func (s *Set) BatchForPaths(index int, paths []string) ([]BatchItem, error) {
key, err := s.SessionKey()
if err != nil {
return nil, err
}
return s.batchForPathsWithKey(index, paths, []byte(key))
}
// HandleBatch decodes an encrypted client BatchRequest body then produces a
// JSON BatchRequest response arranged to match its request order. The result
// is not AES-wrapped: captured BatchRequest responses are JSON arrays too.
func (s *Set) HandleBatch(index int, requestBody []byte, sessionKey []byte) ([]byte, error) {
requests, _, err := DecodeBatchRequest(requestBody, sessionKey)
if err != nil {
return nil, err
}
paths := make([]string, len(requests))
for i, request := range requests {
paths[i] = request.Path
}
items, err := s.batchForPathsCaptured(index, paths)
if err != nil {
return nil, err
}
if err := s.reencryptBatchItems(items, sessionKey); err != nil {
return nil, err
}
return json.Marshal(items)
}
func (s *Set) batchForPathsWithKey(index int, paths []string, key []byte) ([]BatchItem, error) {
items, err := s.batchForPathsCaptured(index, paths)
if err != nil {
return nil, err
}
if err := s.reencryptBatchItems(items, key); err != nil {
return nil, err
}
return items, nil
}
// batchForPathsCaptured selects data using only the captured session key. The
// captured request itself is validated before its response map is trusted.
func (s *Set) batchForPathsCaptured(index int, paths []string) ([]BatchItem, error) {
capturedKey, err := s.SessionKey()
if err != nil {
return nil, err
}
key := []byte(capturedKey)
if err := s.validateBatchRequest(index, key); err != nil {
return nil, err
}
raw, err := s.BatchResponse(index)
if err != nil {
return nil, err
}
items, _, err := DecodeBatch(raw, key)
if err != nil {
return nil, err
}
byPath := make(map[string]BatchItem, len(items))
for _, item := range items {
byPath[item.Path] = item
}
ordered := make([]BatchItem, 0, len(paths))
seen := make(map[string]struct{}, len(paths))
for _, path := range paths {
if _, duplicate := seen[path]; duplicate {
return nil, fmt.Errorf("bd2 fixture: duplicate requested batch path %q", path)
}
seen[path] = struct{}{}
item, ok := byPath[path]
if !ok {
return nil, fmt.Errorf("%w: batch %d path %q", ErrNotFound, index, path)
}
ordered = append(ordered, item)
}
return ordered, nil
}
func (s *Set) reencryptBatchItems(items []BatchItem, localSessionKey []byte) error {
if len(localSessionKey) != cryptox.AESKeySize {
return cryptox.ErrInvalidKey
}
capturedKey, err := s.SessionKey()
if err != nil {
return err
}
for i := range items {
body, err := json.Marshal(items[i].Response)
if err != nil {
return err
}
env, proto, _, err := decodeEnvelope(body, []byte(capturedKey))
if err != nil {
return fmt.Errorf("decode batch response %s: %w", items[i].Path, err)
}
encoded, err := EncodeEnvelope(env, proto, localSessionKey, true)
if err != nil {
return err
}
if err := json.Unmarshal(encoded, &items[i].Response); err != nil {
return err
}
}
return nil
}
func (s *Set) validateBatchRequest(index int, key []byte) error {
var file string
switch index {
case 1:
file = "00040_req_BatchRequest.bin"
case 2:
file = "00076_req_BatchRequest.bin"
default:
return fmt.Errorf("%w: batch %d", ErrNotFound, index)
}
body, err := os.ReadFile(filepath.Join(s.Root, "bodies", file))
if err != nil {
return err
}
_, _, err = DecodeBatchRequest(body, key)
return err
}
func (c *Capture) readResponse(path, file string) (Response, error) {
if err := safeName(file); err != nil {
return Response{}, err
}
body, err := os.ReadFile(filepath.Join(c.Root, "bodies", file))
if err != nil {
return Response{}, fmt.Errorf("read fixture %s: %w", file, err)
}
return Response{Path: path, File: file, Body: body}, nil
}
func safeName(name string) error {
if filepath.Base(name) != name || strings.Contains(name, "..") || name == "" {
return fmt.Errorf("%w: %q", ErrUnsafePath, name)
}
return nil
}
// Envelope is the common game response envelope. Unknown fields are retained
// in Extra, which keeps a replay fixture forward-compatible.
type Envelope struct {
ErrorType int `json:"errorType"`
PacketCode int `json:"packetCode,omitempty"`
ErrorMessage string `json:"errorMessage,omitempty"`
Length int `json:"length"`
Data string `json:"data"`
ServerNowTime int64 `json:"serverNowTime,omitempty"`
Notify string `json:"notify,omitempty"`
Extra json.RawMessage `json:"-"`
}
// DecodeEnvelope accepts either base64(protobuf) or
// base64(AES-CBC(base64(protobuf))). The latter uses key. Captures legitimately
// contain both forms, including after login, so the fallback is intentional.
func DecodeEnvelope(body, key []byte) (Envelope, []byte, error) {
env, proto, _, err := decodeEnvelope(body, key)
return env, proto, err
}
func decodeEnvelope(body, key []byte) (Envelope, []byte, bool, error) {
var env Envelope
if err := json.Unmarshal(body, &env); err != nil {
return Envelope{}, nil, false, fmt.Errorf("decode envelope JSON: %w", err)
}
if env.Data == "" {
if env.Length != 0 {
return Envelope{}, nil, false, fmt.Errorf("%w: advertised %d bytes", ErrNoPayload, env.Length)
}
return env, nil, false, nil
}
plain, encrypted, err := decodeData(env.Data, key)
if err != nil {
return Envelope{}, nil, false, err
}
// In an encrypted envelope length is the character count of the inner
// base64(protobuf), not the protobuf byte count. Direct-base64 bootstrap
// envelopes commonly leave it at zero (MaintenanceInfo is an example).
if encrypted && env.Length != base64.StdEncoding.EncodedLen(len(plain)) {
return Envelope{}, nil, false, fmt.Errorf("%w: advertised=%d wire=%d decoded=%d encrypted=%t", ErrLengthMismatch, env.Length, base64.StdEncoding.EncodedLen(len(plain)), len(plain), encrypted)
}
return env, plain, encrypted, nil
}
func decodeData(data string, key []byte) ([]byte, bool, error) {
if len(key) == cryptox.AESKeySize {
if inner, err := cryptox.DecryptBase64(data, key); err == nil {
if proto, err := base64.StdEncoding.DecodeString(string(inner)); err == nil {
return proto, true, nil
}
}
}
proto, err := base64.StdEncoding.DecodeString(data)
if err != nil {
return nil, false, fmt.Errorf("decode fixture data: %w", err)
}
return proto, false, nil
}
// EncodeEnvelope creates a client-shaped response. encrypted chooses the
// AES-wrapped variant; plain responses use a direct base64 protobuf payload.
func EncodeEnvelope(env Envelope, proto, key []byte, encrypted bool) ([]byte, error) {
copyEnv := env
if encrypted {
if len(key) != cryptox.AESKeySize {
return nil, cryptox.ErrInvalidKey
}
payload, err := cryptox.EncryptBase64Payload(proto, key)
if err != nil {
return nil, err
}
copyEnv.Data = payload
copyEnv.Length = base64.StdEncoding.EncodedLen(len(proto))
} else {
copyEnv.Data = base64.StdEncoding.EncodeToString(proto)
}
return json.Marshal(copyEnv)
}
// BatchItem is one JSON member in a BatchRequest response.
type BatchItem struct {
Path string `json:"path"`
Response Envelope `json:"responseData"`
}
// DecodeBatch parses and validates a BatchRequest response. It rejects
// duplicate endpoint paths because a local dispatcher could not replay them
// deterministically by path.
func DecodeBatch(body, key []byte) ([]BatchItem, map[string][]byte, error) {
var items []BatchItem
if err := json.Unmarshal(body, &items); err != nil {
return nil, nil, fmt.Errorf("decode batch JSON: %w", err)
}
if len(items) == 0 {
return nil, nil, errors.New("bd2 fixture: empty batch response")
}
decoded := make(map[string][]byte, len(items))
for i, item := range items {
if item.Path == "" || !strings.HasPrefix(item.Path, "/") {
return nil, nil, fmt.Errorf("bd2 fixture: batch item %d has invalid path %q", i, item.Path)
}
if _, exists := decoded[item.Path]; exists {
return nil, nil, fmt.Errorf("bd2 fixture: duplicate batch path %q", item.Path)
}
body, err := json.Marshal(item.Response)
if err != nil {
return nil, nil, err
}
_, proto, err := DecodeEnvelope(body, key)
if err != nil {
return nil, nil, fmt.Errorf("decode batch item %s: %w", item.Path, err)
}
decoded[item.Path] = proto
}
return items, decoded, nil
}
// Request describes a decoded BatchRequest member. RequestData is a direct
// base64 protobuf payload; the JSON array itself is AES-wrapped on the wire.
type Request struct {
Path string `json:"path"`
RequestData string `json:"requestData"`
}
func DecodeBatchRequest(body, key []byte) ([]Request, map[string][]byte, error) {
plain, err := cryptox.DecryptBase64(strings.TrimSpace(string(body)), key)
if err != nil {
return nil, nil, fmt.Errorf("decrypt batch request: %w", err)
}
var requests []Request
if err := json.Unmarshal(plain, &requests); err != nil {
return nil, nil, fmt.Errorf("decode batch request JSON: %w", err)
}
decoded := make(map[string][]byte, len(requests))
for i, req := range requests {
if req.Path == "" || !strings.HasPrefix(req.Path, "/") || req.RequestData == "" {
return nil, nil, fmt.Errorf("bd2 fixture: batch request item %d is invalid", i)
}
if _, exists := decoded[req.Path]; exists {
return nil, nil, fmt.Errorf("bd2 fixture: duplicate batch request path %q", req.Path)
}
proto, err := base64.StdEncoding.DecodeString(req.RequestData)
if err != nil {
return nil, nil, fmt.Errorf("decode batch request %s: %w", req.Path, err)
}
decoded[req.Path] = proto
}
return requests, decoded, nil
}
// Paths returns the fixture response paths in lexical order for diagnostics.
func Paths(items []BatchItem) []string {
paths := make([]string, 0, len(items))
for _, item := range items {
paths = append(paths, item.Path)
}
sort.Strings(paths)
return paths
}
// EqualProto deliberately treats nil and empty payloads equally; protocol
// endpoints with response length zero use both representations in practice.
func EqualProto(a, b []byte) bool { return bytes.Equal(a, b) }
var _ fs.FS // document that fixtures deliberately use an on-disk capture.