fix(client): sync local catalog from client setup

This commit is contained in:
2026-09-30 19:43:51 +08:00
parent 99d553753e
commit d57c90962d
3 changed files with 352 additions and 2 deletions
+242
View File
@@ -0,0 +1,242 @@
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)
}
@@ -0,0 +1,99 @@
package setup
import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"testing"
)
func TestValidateLocalCatalogRequiresEveryRemoteBundle(t *testing.T) {
release := t.TempDir()
path := filepath.Join("nested", "current.bundle")
if err := os.MkdirAll(filepath.Join(release, "nested"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(release, path), []byte("bundle"), 0o600); err != nil {
t.Fatal(err)
}
raw, err := json.Marshal(map[string]any{"m_InternalIds": []string{
remoteCatalogPrefix + `StandaloneWindows64\HD\version\nested/current.bundle`,
}})
if err != nil {
t.Fatal(err)
}
if err := validateLocalCatalog(raw, release); err != nil {
t.Fatal(err)
}
if err := os.Remove(filepath.Join(release, path)); err != nil {
t.Fatal(err)
}
if err := validateLocalCatalog(raw, release); err == nil {
t.Fatal("catalog with missing bundle unexpectedly passed")
}
}
func TestCatalogBundlePathRejectsTraversal(t *testing.T) {
if _, err := catalogBundlePath(remoteCatalogPrefix + `StandaloneWindows64\HD\version\..\escape.bundle`); err == nil {
t.Fatal("catalog traversal path unexpectedly passed")
}
}
func TestReplaceCatalogReplacesExistingFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "catalog.json")
if err := os.WriteFile(path, []byte("old"), 0o600); err != nil {
t.Fatal(err)
}
if err := replaceCatalog(path, []byte("new")); err != nil {
t.Fatal(err)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(got) != "new" {
t.Fatalf("catalog=%q", got)
}
}
func TestLocalizeCatalogPreservesMetadataAndCreatesHardLink(t *testing.T) {
release := t.TempDir()
aa := t.TempDir()
relative := filepath.Join("nested", "current.bundle")
if err := os.MkdirAll(filepath.Join(release, "nested"), 0o755); err != nil {
t.Fatal(err)
}
source := filepath.Join(release, relative)
if err := os.WriteFile(source, []byte("bundle"), 0o600); err != nil {
t.Fatal(err)
}
raw, err := json.Marshal(map[string]any{
"m_BuildResultHash": "current-metadata",
"m_InternalIds": []string{
remoteCatalogPrefix + `StandaloneWindows64\HD\version\nested/current.bundle`,
},
})
if err != nil {
t.Fatal(err)
}
localized, err := localizeCatalog(raw, release, aa)
if err != nil {
t.Fatal(err)
}
if !bytes.Contains(localized, []byte(`"m_BuildResultHash":"current-metadata"`)) ||
!bytes.Contains(localized, []byte(`Addressables.RuntimePath`)) {
t.Fatalf("localized catalog=%s", localized)
}
sourceInfo, err := os.Stat(source)
if err != nil {
t.Fatal(err)
}
destinationInfo, err := os.Stat(filepath.Join(aa, relative))
if err != nil {
t.Fatal(err)
}
if !os.SameFile(sourceInfo, destinationInfo) {
t.Fatal("localized bundle is not a hard link to the selected release")
}
}
+11 -2
View File
@@ -82,7 +82,16 @@ func SaveSettings(gameDir string, settings clientconfig.Settings, versions clien
if _, err := Inspect(gameDir, versions); err != nil {
return clientconfig.Settings{}, err
}
return clientconfig.Save(gameDir, settings)
normalized, err := clientconfig.Normalize(settings)
if err != nil {
return clientconfig.Settings{}, err
}
if normalized.CDNMode == clientconfig.CDNLocal {
if err := installCurrentLocalCatalog(gameDir, normalized.LocalResourceDirectory, versions.BundleVersion); err != nil {
return clientconfig.Settings{}, err
}
}
return clientconfig.Save(gameDir, normalized)
}
func Patch(gameDir string, settings clientconfig.Settings, versions clientconfig.ReleaseVersions) (introdb.Result, error) {
@@ -116,7 +125,7 @@ func InstallPlugins(
if !status.BepInEx {
return InstallResult{}, fmt.Errorf("BepInEx is not installed; install it from %s before installing the plugins", plugin.BepInExReleasesURL)
}
if _, err := clientconfig.Save(gameDir, settings); err != nil {
if _, err := SaveSettings(gameDir, settings, versions); err != nil {
return InstallResult{}, err
}
localSource, err := plugin.ResolvePackaged(plugin.LocalIdentity, localIdentitySource)