diff --git a/go/internal/client/setup/local_catalog.go b/go/internal/client/setup/local_catalog.go new file mode 100644 index 0000000..0687ada --- /dev/null +++ b/go/internal/client/setup/local_catalog.go @@ -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) +} diff --git a/go/internal/client/setup/local_catalog_test.go b/go/internal/client/setup/local_catalog_test.go new file mode 100644 index 0000000..6a34669 --- /dev/null +++ b/go/internal/client/setup/local_catalog_test.go @@ -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") + } +} diff --git a/go/internal/client/setup/setup.go b/go/internal/client/setup/setup.go index 50cb622..7d213a8 100644 --- a/go/internal/client/setup/setup.go +++ b/go/internal/client/setup/setup.go @@ -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)