feat: split client tooling and add OAuth server login
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
package introdb
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/hmac"
|
||||
"crypto/sha1"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
const PageSize = 4096
|
||||
|
||||
var Header = []byte("SQLite format 3\x00")
|
||||
|
||||
func decryptPages(in []byte) ([]byte, error) { return cryptPages(in, false) }
|
||||
func encryptPages(in []byte) ([]byte, error) { return cryptPages(in, true) }
|
||||
|
||||
func cryptPages(in []byte, encrypt bool) ([]byte, error) {
|
||||
if len(in) == 0 || len(in)%PageSize != 0 {
|
||||
return nil, fmt.Errorf("dbcrypt: database length %d is not a non-zero multiple of %d", len(in), PageSize)
|
||||
}
|
||||
block, err := aes.NewCipher(deriveKey())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]byte, len(in))
|
||||
for start := 0; start < len(in); start += PageSize {
|
||||
var mode cipher.BlockMode = cipher.NewCBCEncrypter(block, Header)
|
||||
if !encrypt {
|
||||
mode = cipher.NewCBCDecrypter(block, Header)
|
||||
}
|
||||
mode.CryptBlocks(out[start:start+PageSize], in[start:start+PageSize])
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func deriveKey() []byte {
|
||||
password := []byte(fmt.Sprintf("%X", sha1.Sum([]byte("spdhdnlwmrpavmtm"))))
|
||||
return pbkdf2SHA1(password, Header, 2010, 32)
|
||||
}
|
||||
|
||||
func pbkdf2SHA1(password, salt []byte, iterations, length int) []byte {
|
||||
var result []byte
|
||||
for block := uint32(1); len(result) < length; block++ {
|
||||
message := append(append([]byte{}, salt...), byte(block>>24), byte(block>>16), byte(block>>8), byte(block))
|
||||
u := hmacSHA1(password, message)
|
||||
t := append([]byte{}, u...)
|
||||
for i := 1; i < iterations; i++ {
|
||||
u = hmacSHA1(password, u)
|
||||
for j := range t {
|
||||
t[j] ^= u[j]
|
||||
}
|
||||
}
|
||||
result = append(result, t...)
|
||||
}
|
||||
return result[:length]
|
||||
}
|
||||
|
||||
func hmacSHA1(key, message []byte) []byte {
|
||||
h := hmac.New(sha1.New, key)
|
||||
_, _ = h.Write(message)
|
||||
return h.Sum(nil)
|
||||
}
|
||||
@@ -0,0 +1,486 @@
|
||||
// Package introdb patches Brown Dust II's embedded Intro database without
|
||||
// changing the size or layout of Unity's resources.assets file.
|
||||
package introdb
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
clientlayout "bd2server/internal/client/layout"
|
||||
)
|
||||
|
||||
const (
|
||||
oldURL = "https://mt.bd2.pmang.cloud/"
|
||||
)
|
||||
|
||||
var salt = Header
|
||||
|
||||
// Result describes a completed in-place client patch. BackupPath is the
|
||||
// immutable pre-patch copy and is never overwritten by a later invocation.
|
||||
type Result struct {
|
||||
AssetsPath string
|
||||
BackupPath string
|
||||
ObjectPath int64
|
||||
ObjectSize uint32
|
||||
OldURL string
|
||||
NewURL string
|
||||
Changed bool
|
||||
}
|
||||
|
||||
// VerifyResult is useful to patch-client's --verify mode and to diagnostics.
|
||||
type VerifyResult struct {
|
||||
AssetsPath string
|
||||
ObjectPath int64
|
||||
ObjectSize uint32
|
||||
URL string
|
||||
}
|
||||
|
||||
// PatchClient resolves resources.assets below gameDir, verifies the embedded
|
||||
// TextAsset named Intro, makes a one-time .bak copy, then atomically replaces
|
||||
// resources.assets. newURL must be exactly as long as the original URL.
|
||||
func PatchClient(gameDir, newURL string) (Result, error) {
|
||||
if len(newURL) != len(oldURL) {
|
||||
return Result{}, fmt.Errorf("introdb: local URL must be exactly %d bytes (got %d): %q", len(oldURL), len(newURL), newURL)
|
||||
}
|
||||
if !isASCII(newURL) {
|
||||
return Result{}, errors.New("introdb: local URL must be ASCII")
|
||||
}
|
||||
assets, err := ResourcesPath(gameDir)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
b, err := os.ReadFile(assets)
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("introdb: read assets: %w", err)
|
||||
}
|
||||
entry, script, err := findIntro(b)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
plain, err := DecryptPages(script)
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("introdb: decrypt Intro TextAsset: %w", err)
|
||||
}
|
||||
// A repeated command is intentionally a no-op. This makes automation safe
|
||||
// while refusing to overwrite an Intro database that was patched to a
|
||||
// different endpoint by another tool.
|
||||
if bytes.Count(plain, []byte(oldURL)) == 0 {
|
||||
current, findErr := urlInDB(plain)
|
||||
if findErr != nil {
|
||||
return Result{}, findErr
|
||||
}
|
||||
if current == newURL {
|
||||
return Result{assets, assets + ".bak", entry.pathID, entry.size, current, newURL, false}, nil
|
||||
}
|
||||
return Result{}, fmt.Errorf("introdb: LIVE_URL is already %q, not the expected official URL", current)
|
||||
}
|
||||
if err := validateIntroDB(plain, oldURL); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
updated := bytes.Replace(plain, []byte(oldURL), []byte(newURL), 1)
|
||||
ciphertext, err := EncryptPages(updated)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
copy(b[entry.scriptStart:entry.scriptStart+int64(len(ciphertext))], ciphertext)
|
||||
// Verify the staged bytes before touching the installed asset. This catches
|
||||
// a parser/cipher regression even though the payload length never changes.
|
||||
_, stagedScript, err := findIntro(b)
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("introdb: re-read staged Intro: %w", err)
|
||||
}
|
||||
stagedPlain, err := DecryptPages(stagedScript)
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("introdb: decrypt staged Intro: %w", err)
|
||||
}
|
||||
if err := validateIntroDB(stagedPlain, newURL); err != nil {
|
||||
return Result{}, fmt.Errorf("introdb: staged verification: %w", err)
|
||||
}
|
||||
|
||||
backup := assets + ".bak"
|
||||
if _, err := os.Stat(backup); errors.Is(err, os.ErrNotExist) {
|
||||
if err := copyFile(assets, backup); err != nil {
|
||||
return Result{}, fmt.Errorf("introdb: create backup: %w", err)
|
||||
}
|
||||
} else if err != nil {
|
||||
return Result{}, fmt.Errorf("introdb: inspect backup: %w", err)
|
||||
}
|
||||
if err := atomicWrite(assets, b); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
return Result{assets, backup, entry.pathID, entry.size, oldURL, newURL, true}, nil
|
||||
}
|
||||
|
||||
// VerifyClient reads and decrypts the embedded Intro TextAsset. It verifies
|
||||
// the SQLite header plus the expected table/key markers before returning URL.
|
||||
func VerifyClient(gameDir string) (VerifyResult, error) {
|
||||
assets, err := ResourcesPath(gameDir)
|
||||
if err != nil {
|
||||
return VerifyResult{}, err
|
||||
}
|
||||
b, err := os.ReadFile(assets)
|
||||
if err != nil {
|
||||
return VerifyResult{}, fmt.Errorf("introdb: read assets: %w", err)
|
||||
}
|
||||
e, script, err := findIntro(b)
|
||||
if err != nil {
|
||||
return VerifyResult{}, err
|
||||
}
|
||||
p, err := DecryptPages(script)
|
||||
if err != nil {
|
||||
return VerifyResult{}, fmt.Errorf("introdb: decrypt Intro TextAsset: %w", err)
|
||||
}
|
||||
if !bytes.HasPrefix(p, salt) {
|
||||
return VerifyResult{}, errors.New("introdb: decrypted Intro is not a SQLite database")
|
||||
}
|
||||
if !bytes.Contains(p, []byte("ServerURLTable")) || !bytes.Contains(p, []byte("LIVE_URL")) {
|
||||
return VerifyResult{}, errors.New("introdb: Intro database lacks ServerURLTable/LIVE_URL markers")
|
||||
}
|
||||
url, err := urlInDB(p)
|
||||
if err != nil {
|
||||
return VerifyResult{}, err
|
||||
}
|
||||
return VerifyResult{assets, e.pathID, e.size, url}, nil
|
||||
}
|
||||
|
||||
// ResourcesPath returns the conventional standalone Windows resource location.
|
||||
func ResourcesPath(gameDir string) (string, error) {
|
||||
if gameDir == "" {
|
||||
return "", errors.New("introdb: empty game directory")
|
||||
}
|
||||
installation, err := clientlayout.Resolve(gameDir)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("introdb: resolve game layout: %w", err)
|
||||
}
|
||||
p := installation.Resources
|
||||
st, err := os.Stat(p)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("introdb: resources.assets not found at %q: %w", p, err)
|
||||
}
|
||||
if st.IsDir() {
|
||||
return "", fmt.Errorf("introdb: resources.assets path is a directory: %q", p)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// DecryptPages decrypts the game's independent 4096-byte AES-CBC pages.
|
||||
func DecryptPages(in []byte) ([]byte, error) { return decryptPages(in) }
|
||||
|
||||
// EncryptPages encrypts the game's independent 4096-byte AES-CBC pages.
|
||||
func EncryptPages(in []byte) ([]byte, error) { return encryptPages(in) }
|
||||
|
||||
func validateIntroDB(p []byte, expected string) error {
|
||||
if !bytes.HasPrefix(p, salt) {
|
||||
return errors.New("introdb: decrypted Intro is not a SQLite database (wrong cipher parameters or asset)")
|
||||
}
|
||||
if !bytes.Contains(p, []byte("ServerURLTable")) || !bytes.Contains(p, []byte("LIVE_URL")) {
|
||||
return errors.New("introdb: database does not contain ServerURLTable/LIVE_URL")
|
||||
}
|
||||
if bytes.Count(p, []byte(expected)) != 1 {
|
||||
return fmt.Errorf("introdb: expected LIVE_URL value %q exactly once, found %d", expected, bytes.Count(p, []byte(expected)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func urlInDB(p []byte) (string, error) {
|
||||
if bytes.Count(p, []byte(oldURL)) == 1 {
|
||||
return oldURL, nil
|
||||
}
|
||||
// Patching stays strictly exact-length, so an already-patched value can be
|
||||
// diagnosed without needing a SQLite C dependency. Search only the record
|
||||
// containing LIVE_URL; unrelated configuration URLs are also present.
|
||||
marker := []byte("LIVE_URL")
|
||||
for start := 0; ; {
|
||||
i := bytes.Index(p[start:], marker)
|
||||
if i < 0 {
|
||||
break
|
||||
}
|
||||
i += start + len(marker)
|
||||
limit := i + 128
|
||||
if limit > len(p) {
|
||||
limit = len(p)
|
||||
}
|
||||
if j := bytes.Index(p[i:limit], []byte("http")); j >= 0 {
|
||||
at := i + j
|
||||
if at+len(oldURL) <= len(p) {
|
||||
u := p[at : at+len(oldURL)]
|
||||
if bytes.HasSuffix(u, []byte("/")) && isASCII(string(u)) {
|
||||
return string(u), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
start = i
|
||||
}
|
||||
return "", errors.New("introdb: cannot locate LIVE_URL value")
|
||||
}
|
||||
func isASCII(s string) bool {
|
||||
for _, c := range []byte(s) {
|
||||
if c < 0x20 || c > 0x7e {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
func copyFile(src, dst string) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, e := io.Copy(out, in)
|
||||
ce := out.Close()
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
return ce
|
||||
}
|
||||
func atomicWrite(path string, data []byte) error {
|
||||
tmp := path + ".bd2server.tmp"
|
||||
f, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
|
||||
if err != nil {
|
||||
return fmt.Errorf("introdb: stage patched assets: %w", err)
|
||||
}
|
||||
if _, err := f.Write(data); err != nil {
|
||||
_ = f.Close()
|
||||
return fmt.Errorf("introdb: stage patched assets: %w", err)
|
||||
}
|
||||
if err := f.Sync(); err != nil {
|
||||
_ = f.Close()
|
||||
return fmt.Errorf("introdb: sync staged assets: %w", err)
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
return fmt.Errorf("introdb: close staged assets: %w", err)
|
||||
}
|
||||
// Go's Windows implementation replaces an existing destination (covered by
|
||||
// TestPatchClientTransaction). Prefer that one-step replacement. A rollback
|
||||
// path remains for Windows filesystems that reject replacement by rename.
|
||||
if err := os.Rename(tmp, path); err == nil {
|
||||
return nil
|
||||
}
|
||||
rollback := path + ".bd2server.rollback"
|
||||
if _, err := os.Stat(rollback); err == nil {
|
||||
return fmt.Errorf("introdb: cannot safely replace assets; rollback file exists: %q", rollback)
|
||||
}
|
||||
if err := os.Rename(path, rollback); err != nil {
|
||||
return fmt.Errorf("introdb: replace assets: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
if restoreErr := os.Rename(rollback, path); restoreErr != nil {
|
||||
return fmt.Errorf("introdb: replacement failed (%v) and rollback restore failed (%v); original is %q", err, restoreErr, rollback)
|
||||
}
|
||||
return fmt.Errorf("introdb: replace assets (original restored): %w", err)
|
||||
}
|
||||
if err := os.Remove(rollback); err != nil {
|
||||
return fmt.Errorf("introdb: patched successfully but could not remove rollback copy %q: %w", rollback, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Kept here so the object parser and patch transaction stay together.
|
||||
type textAsset struct {
|
||||
pathID int64
|
||||
size uint32
|
||||
scriptStart int64
|
||||
scriptSize int
|
||||
}
|
||||
type reader struct {
|
||||
b []byte
|
||||
off int
|
||||
little bool
|
||||
}
|
||||
|
||||
func (r *reader) need(n int) error {
|
||||
if n < 0 || r.off+n > len(r.b) {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (r *reader) u8() (byte, error) {
|
||||
if err := r.need(1); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
v := r.b[r.off]
|
||||
r.off++
|
||||
return v, nil
|
||||
}
|
||||
func (r *reader) u16() (uint16, error) {
|
||||
if err := r.need(2); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
v := binary.LittleEndian.Uint16(r.b[r.off:])
|
||||
if !r.little {
|
||||
v = binary.BigEndian.Uint16(r.b[r.off:])
|
||||
}
|
||||
r.off += 2
|
||||
return v, nil
|
||||
}
|
||||
func (r *reader) u32() (uint32, error) {
|
||||
if err := r.need(4); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
v := binary.LittleEndian.Uint32(r.b[r.off:])
|
||||
if !r.little {
|
||||
v = binary.BigEndian.Uint32(r.b[r.off:])
|
||||
}
|
||||
r.off += 4
|
||||
return v, nil
|
||||
}
|
||||
func (r *reader) i32() (int32, error) { v, e := r.u32(); return int32(v), e }
|
||||
func (r *reader) i64() (int64, error) {
|
||||
if e := r.need(8); e != nil {
|
||||
return 0, e
|
||||
}
|
||||
v := binary.LittleEndian.Uint64(r.b[r.off:])
|
||||
if !r.little {
|
||||
v = binary.BigEndian.Uint64(r.b[r.off:])
|
||||
}
|
||||
r.off += 8
|
||||
return int64(v), nil
|
||||
}
|
||||
func (r *reader) u64() (uint64, error) { v, e := r.i64(); return uint64(v), e }
|
||||
func (r *reader) skip(n int) error {
|
||||
if e := r.need(n); e != nil {
|
||||
return e
|
||||
}
|
||||
r.off += n
|
||||
return nil
|
||||
}
|
||||
func (r *reader) align4() { r.off = (r.off + 3) &^ 3 }
|
||||
func (r *reader) str() (string, error) {
|
||||
n, e := r.u32()
|
||||
if e != nil {
|
||||
return "", e
|
||||
}
|
||||
if n > uint32(len(r.b)-r.off) {
|
||||
return "", io.ErrUnexpectedEOF
|
||||
}
|
||||
s := string(r.b[r.off : r.off+int(n)])
|
||||
r.off += int(n)
|
||||
r.align4()
|
||||
return s, nil
|
||||
}
|
||||
func (r *reader) cstr() (string, error) {
|
||||
start := r.off
|
||||
for r.off < len(r.b) && r.b[r.off] != 0 {
|
||||
r.off++
|
||||
}
|
||||
if r.off == len(r.b) {
|
||||
return "", io.ErrUnexpectedEOF
|
||||
}
|
||||
s := string(r.b[start:r.off])
|
||||
r.off++
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func findIntro(file []byte) (textAsset, []byte, error) {
|
||||
if len(file) < 48 {
|
||||
return textAsset{}, nil, errors.New("introdb: truncated Unity serialized file")
|
||||
}
|
||||
version := binary.BigEndian.Uint32(file[8:12])
|
||||
if version < 14 {
|
||||
return textAsset{}, nil, fmt.Errorf("introdb: Unity serialized version %d is unsupported", version)
|
||||
}
|
||||
dataOff := binary.BigEndian.Uint64(file[32:40])
|
||||
if dataOff >= uint64(len(file)) {
|
||||
return textAsset{}, nil, errors.New("introdb: invalid Unity data offset")
|
||||
}
|
||||
// Unity records endian at byte 16: 0 means little endian for metadata.
|
||||
r := reader{b: file[48:int(dataOff)], little: file[16] == 0}
|
||||
if _, e := r.cstr(); e != nil {
|
||||
return textAsset{}, nil, fmt.Errorf("introdb: user version: %w", e)
|
||||
}
|
||||
if _, e := r.i32(); e != nil {
|
||||
return textAsset{}, nil, e
|
||||
}
|
||||
if _, e := r.u8(); e != nil {
|
||||
return textAsset{}, nil, e
|
||||
}
|
||||
typeCount, e := r.i32()
|
||||
if e != nil || typeCount < 0 || typeCount > 100000 {
|
||||
return textAsset{}, nil, fmt.Errorf("introdb: invalid type count %d", typeCount)
|
||||
}
|
||||
classes := make([]int32, typeCount)
|
||||
for i := range classes {
|
||||
c, e := r.i32()
|
||||
if e != nil {
|
||||
return textAsset{}, nil, e
|
||||
}
|
||||
classes[i] = c
|
||||
if _, e = r.u8(); e != nil {
|
||||
return textAsset{}, nil, e
|
||||
}
|
||||
if _, e = r.u16(); e != nil {
|
||||
return textAsset{}, nil, e
|
||||
}
|
||||
if c == 114 {
|
||||
if e = r.skip(16); e != nil {
|
||||
return textAsset{}, nil, e
|
||||
}
|
||||
}
|
||||
if e = r.skip(16); e != nil {
|
||||
return textAsset{}, nil, e
|
||||
}
|
||||
}
|
||||
count, e := r.i32()
|
||||
if e != nil || count < 0 || count > 10000000 {
|
||||
return textAsset{}, nil, fmt.Errorf("introdb: invalid object count %d", count)
|
||||
}
|
||||
var candidates []textAsset
|
||||
for i := int32(0); i < count; i++ {
|
||||
// Since serialized version 14, Unity aligns object records to four bytes
|
||||
// before their 64-bit path ID (not to an eight-byte boundary).
|
||||
r.off = (r.off + 48 + 3) &^ 3
|
||||
r.off -= 48
|
||||
pid, e := r.i64()
|
||||
if e != nil {
|
||||
return textAsset{}, nil, e
|
||||
}
|
||||
start, e := r.u64()
|
||||
if e != nil {
|
||||
return textAsset{}, nil, e
|
||||
}
|
||||
size, e := r.u32()
|
||||
if e != nil {
|
||||
return textAsset{}, nil, e
|
||||
}
|
||||
typ, e := r.i32()
|
||||
if e != nil {
|
||||
return textAsset{}, nil, e
|
||||
}
|
||||
if typ < 0 || int(typ) >= len(classes) {
|
||||
return textAsset{}, nil, fmt.Errorf("introdb: object %d has invalid type ID %d", i, typ)
|
||||
}
|
||||
if classes[typ] != 49 {
|
||||
continue
|
||||
}
|
||||
abs := int64(dataOff) + int64(start)
|
||||
if abs < 0 || abs+int64(size) > int64(len(file)) {
|
||||
return textAsset{}, nil, fmt.Errorf("introdb: TextAsset object %d range outside file (start=%d size=%d)", i, start, size)
|
||||
}
|
||||
candidates = append(candidates, textAsset{pathID: pid, size: size, scriptStart: abs})
|
||||
}
|
||||
for _, c := range candidates {
|
||||
object := reader{b: file[c.scriptStart : c.scriptStart+int64(c.size)], little: r.little}
|
||||
name, e := object.str()
|
||||
if e != nil {
|
||||
continue
|
||||
}
|
||||
n, e := object.u32()
|
||||
if e != nil || n > uint32(len(object.b)-object.off) {
|
||||
continue
|
||||
}
|
||||
if name == "Intro" {
|
||||
c.scriptStart += int64(object.off)
|
||||
c.scriptSize = int(n)
|
||||
return c, file[c.scriptStart : c.scriptStart+int64(n)], nil
|
||||
}
|
||||
}
|
||||
return textAsset{}, nil, errors.New("introdb: TextAsset named Intro was not found")
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package introdb
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func referenceClientDir(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := os.Getenv("BD2_TEST_CLIENT_DIR")
|
||||
if dir == "" {
|
||||
t.Skip("set BD2_TEST_CLIENT_DIR to enable read-only client integration tests")
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
func TestPagesRoundTrip(t *testing.T) {
|
||||
p := make([]byte, PageSize*2)
|
||||
copy(p, salt)
|
||||
for i := 16; i < len(p); i++ {
|
||||
p[i] = byte(i * 31)
|
||||
}
|
||||
c, err := EncryptPages(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := DecryptPages(c)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(got, p) {
|
||||
t.Fatal("page cipher did not round-trip")
|
||||
}
|
||||
}
|
||||
func TestPagesRejectPartialPage(t *testing.T) {
|
||||
if _, err := DecryptPages(make([]byte, PageSize-1)); err == nil {
|
||||
t.Fatal("accepted partial page")
|
||||
}
|
||||
}
|
||||
func TestValidateRequiresUniqueURL(t *testing.T) {
|
||||
p := append([]byte{}, salt...)
|
||||
p = append(p, []byte(" ServerURLTable LIVE_URL "+oldURL)...)
|
||||
if err := validateIntroDB(p, oldURL); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := validateIntroDB(append(p, []byte(oldURL)...), oldURL); err == nil {
|
||||
t.Fatal("accepted duplicated URL")
|
||||
}
|
||||
}
|
||||
|
||||
// This is deliberately read-only. When the reference client is present, it
|
||||
// proves the Unity metadata parser and crypto parameters against the real file.
|
||||
func TestKnownClientVerify(t *testing.T) {
|
||||
knownClientDir := referenceClientDir(t)
|
||||
if _, err := os.Stat(knownClientDir); os.IsNotExist(err) {
|
||||
t.Skip("reference client is not available")
|
||||
}
|
||||
v, err := VerifyClient(knownClientDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v.URL != oldURL && v.URL != "http://127.0.0.1:8080/game/" {
|
||||
t.Fatalf("unexpected LIVE_URL=%q", v.URL)
|
||||
}
|
||||
}
|
||||
|
||||
// The transaction test copies the reference asset into a test directory, never
|
||||
// mutating the installed client. It covers Unity object lookup, backup,
|
||||
// encryption, atomic replacement, and the post-patch diagnostic together.
|
||||
func TestPatchClientTransaction(t *testing.T) {
|
||||
knownClientDir := referenceClientDir(t)
|
||||
if _, err := os.Stat(knownClientDir); os.IsNotExist(err) {
|
||||
t.Skip("reference client is not available")
|
||||
}
|
||||
tmp := t.TempDir()
|
||||
dir := filepath.Join(tmp, "BrownDust II_Data")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
src, err := ResourcesPath(knownClientDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if current, err := VerifyClient(knownClientDir); err == nil && current.URL != oldURL {
|
||||
// The installed research client is normally patched. Exercise the
|
||||
// transaction against its immutable pre-patch backup in that case.
|
||||
if _, err := os.Stat(src + ".bak"); err != nil {
|
||||
t.Skipf("official pre-patch asset is unavailable: %v", err)
|
||||
}
|
||||
src += ".bak"
|
||||
}
|
||||
dst := filepath.Join(dir, "resources.assets")
|
||||
in, err := os.ReadFile(src)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(dst, in, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const local = "http://127.0.0.1:8080/bd2x/"
|
||||
r, err := PatchClient(tmp, local)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r.OldURL != oldURL || r.NewURL != local || r.BackupPath != dst+".bak" {
|
||||
t.Fatalf("unexpected patch result: %#v", r)
|
||||
}
|
||||
if !r.Changed {
|
||||
t.Fatal("first patch was not reported as changed")
|
||||
}
|
||||
if _, err := os.Stat(r.BackupPath); err != nil {
|
||||
t.Fatalf("backup missing: %v", err)
|
||||
}
|
||||
v, err := VerifyClient(tmp)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v.URL != local {
|
||||
t.Fatalf("LIVE_URL=%q, want %q", v.URL, local)
|
||||
}
|
||||
repeated, err := PatchClient(tmp, local)
|
||||
if err != nil {
|
||||
t.Fatalf("idempotent patch: %v", err)
|
||||
}
|
||||
if repeated.Changed {
|
||||
t.Fatal("idempotent patch was reported as changed")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user