Files
bd2/go/internal/client/setup/local_catalog.go
T

243 lines
7.5 KiB
Go

package setup
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
clientlayout "bd2server/internal/client/layout"
)
const (
remoteCatalogPrefix = "{BDNetwork.CdnInfo.Info}\\"
localCatalogPrefix = "{UnityEngine.AddressableAssets.Addressables.RuntimePath}\\"
localCatalogBackup = "catalog.json.bd2-before-local-sync"
maximumCatalogBytes = 128 << 20
)
func installCurrentLocalCatalog(gameDir, localRoot, bundleVersion string) error {
installation, err := clientlayout.Resolve(gameDir)
if err != nil {
return err
}
release := filepath.Join(filepath.Clean(localRoot), "ServerData", "StandaloneWindows64", "HD", bundleVersion)
source := filepath.Join(release, "catalog_alpha.json")
raw, err := readCatalog(source)
if err != nil {
return err
}
aa := filepath.Join(installation.Data, "StreamingAssets", "aa")
raw, err = localizeCatalog(raw, release, aa)
if err != nil {
return err
}
target := filepath.Join(aa, "catalog.json")
current, err := os.ReadFile(target)
if err != nil {
return fmt.Errorf("read built-in Addressables catalog: %w", err)
}
if bytes.Equal(current, raw) {
return nil
}
backup := filepath.Join(aa, localCatalogBackup)
if _, err := os.Stat(backup); errors.Is(err, os.ErrNotExist) {
if err := writeExclusiveFile(backup, current, 0o600); err != nil {
return fmt.Errorf("back up built-in Addressables catalog: %w", err)
}
} else if err != nil {
return fmt.Errorf("inspect built-in Addressables catalog backup: %w", err)
}
if err := replaceCatalog(target, raw); err != nil {
return fmt.Errorf("install current local Addressables catalog: %w", err)
}
return nil
}
func readCatalog(path string) ([]byte, error) {
file, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("open local ServerData catalog: %w", err)
}
defer file.Close()
raw, err := io.ReadAll(io.LimitReader(file, maximumCatalogBytes+1))
if err != nil {
return nil, fmt.Errorf("read local ServerData catalog: %w", err)
}
if len(raw) == 0 || len(raw) > maximumCatalogBytes {
return nil, errors.New("local ServerData catalog has an invalid size")
}
return raw, nil
}
func validateLocalCatalog(raw []byte, release string) error {
_, err := localizeCatalogIDs(raw, release)
return err
}
func localizeCatalog(raw []byte, release, aa string) ([]byte, error) {
document, ids, err := decodeCatalog(raw)
if err != nil {
return nil, err
}
remoteCount := 0
for index, internalID := range ids {
if strings.HasPrefix(internalID, remoteCatalogPrefix) {
relative, err := catalogBundlePath(internalID)
if err != nil {
return nil, err
}
source := filepath.Join(release, relative)
destination := filepath.Join(aa, relative)
if err := ensureCatalogBundleLink(source, destination); err != nil {
return nil, err
}
ids[index] = localCatalogPrefix + strings.ReplaceAll(filepath.ToSlash(relative), "/", "\\")
remoteCount++
continue
}
if strings.HasPrefix(internalID, localCatalogPrefix) {
relative := strings.TrimPrefix(internalID, localCatalogPrefix)
relative = filepath.FromSlash(strings.ReplaceAll(relative, "\\", "/"))
if info, err := os.Stat(filepath.Join(aa, relative)); err != nil || !info.Mode().IsRegular() {
return nil, fmt.Errorf("built-in Addressables catalog references a missing local bundle: %s", relative)
}
}
}
if remoteCount == 0 {
return nil, errors.New("local ServerData catalog has no CDN bundle entries")
}
encodedIDs, err := json.Marshal(ids)
if err != nil {
return nil, err
}
document["m_InternalIds"] = encodedIDs
localized, err := json.Marshal(document)
if err != nil {
return nil, fmt.Errorf("encode localized ServerData catalog: %w", err)
}
return localized, nil
}
func localizeCatalogIDs(raw []byte, release string) ([]string, error) {
_, ids, err := decodeCatalog(raw)
if err != nil {
return nil, err
}
remoteCount := 0
for _, internalID := range ids {
if !strings.HasPrefix(internalID, remoteCatalogPrefix) {
continue
}
relative, err := catalogBundlePath(internalID)
if err != nil {
return nil, err
}
if info, err := os.Stat(filepath.Join(release, relative)); err != nil || !info.Mode().IsRegular() {
return nil, fmt.Errorf("local ServerData catalog references a missing bundle: %s", relative)
}
remoteCount++
}
if remoteCount == 0 {
return nil, errors.New("local ServerData catalog has no CDN bundle entries")
}
return ids, nil
}
func decodeCatalog(raw []byte) (map[string]json.RawMessage, []string, error) {
var document map[string]json.RawMessage
decoder := json.NewDecoder(bytes.NewReader(raw))
if err := decoder.Decode(&document); err != nil {
return nil, nil, fmt.Errorf("decode local ServerData catalog: %w", err)
}
var trailing any
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
return nil, nil, errors.New("local ServerData catalog must contain exactly one JSON object")
}
var ids []string
if err := json.Unmarshal(document["m_InternalIds"], &ids); err != nil || len(ids) == 0 {
return nil, nil, errors.New("local ServerData catalog has no valid internal IDs")
}
return document, ids, nil
}
func ensureCatalogBundleLink(source, destination string) error {
sourceInfo, err := os.Stat(source)
if err != nil || !sourceInfo.Mode().IsRegular() {
return fmt.Errorf("local ServerData catalog references a missing bundle: %s", source)
}
if destinationInfo, err := os.Stat(destination); err == nil {
if !destinationInfo.Mode().IsRegular() || !os.SameFile(sourceInfo, destinationInfo) {
return fmt.Errorf("game Addressables bundle conflicts with the selected local release: %s", destination)
}
return nil
} else if !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("inspect game Addressables bundle: %w", err)
}
if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil {
return fmt.Errorf("create game Addressables bundle directory: %w", err)
}
if err := os.Link(source, destination); err != nil {
return fmt.Errorf("hard-link local bundle without duplicating it (keep the game and local resources on the same filesystem): %w", err)
}
return nil
}
func catalogBundlePath(internalID string) (string, error) {
parts := strings.Split(strings.ReplaceAll(internalID, "\\", "/"), "/")
if len(parts) < 5 {
return "", fmt.Errorf("local ServerData catalog has an invalid CDN path: %q", internalID)
}
parts = parts[4:]
for _, part := range parts {
if part == "" || part == "." || part == ".." || strings.ContainsAny(part, `:{}`) {
return "", fmt.Errorf("local ServerData catalog has an unsafe bundle path: %q", internalID)
}
}
relative := filepath.Join(parts...)
if !strings.EqualFold(filepath.Ext(relative), ".bundle") {
return "", fmt.Errorf("local ServerData catalog CDN path is not a bundle: %q", internalID)
}
return relative, nil
}
func writeExclusiveFile(path string, data []byte, mode os.FileMode) error {
file, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, mode)
if err != nil {
return err
}
if _, err = file.Write(data); err == nil {
err = file.Sync()
}
if closeErr := file.Close(); err == nil {
err = closeErr
}
return err
}
func replaceCatalog(path string, data []byte) error {
temporary, err := os.CreateTemp(filepath.Dir(path), ".catalog-local-*.json")
if err != nil {
return err
}
temporaryPath := temporary.Name()
defer os.Remove(temporaryPath)
if err = temporary.Chmod(0o600); err == nil {
_, err = temporary.Write(data)
}
if err == nil {
err = temporary.Sync()
}
if closeErr := temporary.Close(); err == nil {
err = closeErr
}
if err != nil {
return err
}
return os.Rename(temporaryPath, path)
}