fix(client): sync local catalog from client setup
This commit is contained in:
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user