283 lines
8.9 KiB
Go
283 lines
8.9 KiB
Go
// Package gamedata validates the versioned archive served to the client.
|
|
package gamedata
|
|
|
|
import (
|
|
"archive/zip"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
ArchiveName = "common-dbdata.bin"
|
|
InfoName = "common-dbdata.info"
|
|
)
|
|
|
|
// Result describes a completely readable GameData archive. Validation reads
|
|
// every entry to EOF, which makes archive/zip verify each entry's CRC32.
|
|
type Result struct {
|
|
ArchivePath string
|
|
ArchiveSize int64
|
|
EntryCount int
|
|
UncompressedSize int64
|
|
}
|
|
|
|
// Ensure returns a verified local archive. If local validation fails, it
|
|
// downloads the official size metadata and archive into staging files,
|
|
// validates the complete ZIP (including every entry CRC), and only then
|
|
// replaces the bad files. Existing files are preserved as .broken.* backups.
|
|
func Ensure(ctx context.Context, client *http.Client, root, version, origin string) (Result, bool, error) {
|
|
if result, err := Validate(root, version); err == nil {
|
|
return result, false, nil
|
|
}
|
|
if client == nil {
|
|
client = &http.Client{Timeout: 30 * time.Minute}
|
|
}
|
|
origin = strings.TrimRight(origin, "/")
|
|
if origin == "" {
|
|
return Result{}, false, fmt.Errorf("gamedata: download origin is empty")
|
|
}
|
|
release := filepath.Join(root, version, "release")
|
|
if err := os.MkdirAll(release, 0o755); err != nil {
|
|
return Result{}, false, fmt.Errorf("gamedata: create release directory: %w", err)
|
|
}
|
|
baseURL := origin + "/" + version + "/release/"
|
|
|
|
infoBytes, err := downloadSmall(ctx, client, baseURL+InfoName, 1024)
|
|
if err != nil {
|
|
return Result{}, false, err
|
|
}
|
|
expected, err := parseExpectedSize(infoBytes)
|
|
if err != nil {
|
|
return Result{}, false, err
|
|
}
|
|
|
|
archiveStage, err := os.CreateTemp(release, ".common-dbdata-*.tmp")
|
|
if err != nil {
|
|
return Result{}, false, fmt.Errorf("gamedata: create archive staging file: %w", err)
|
|
}
|
|
archiveStagePath := archiveStage.Name()
|
|
keepStage := false
|
|
defer func() {
|
|
_ = archiveStage.Close()
|
|
if !keepStage {
|
|
_ = os.Remove(archiveStagePath)
|
|
}
|
|
}()
|
|
if err := downloadExact(ctx, client, baseURL+ArchiveName, archiveStage, expected); err != nil {
|
|
return Result{}, false, err
|
|
}
|
|
if err := archiveStage.Sync(); err != nil {
|
|
return Result{}, false, fmt.Errorf("gamedata: sync staged archive: %w", err)
|
|
}
|
|
if err := archiveStage.Close(); err != nil {
|
|
return Result{}, false, fmt.Errorf("gamedata: close staged archive: %w", err)
|
|
}
|
|
verified, err := validateArchive(archiveStagePath, expected)
|
|
if err != nil {
|
|
return Result{}, false, fmt.Errorf("gamedata: downloaded archive failed validation: %w", err)
|
|
}
|
|
|
|
infoStage, err := os.CreateTemp(release, ".common-dbdata-info-*.tmp")
|
|
if err != nil {
|
|
return Result{}, false, fmt.Errorf("gamedata: create info staging file: %w", err)
|
|
}
|
|
infoStagePath := infoStage.Name()
|
|
defer os.Remove(infoStagePath)
|
|
if _, err := infoStage.Write(infoBytes); err != nil {
|
|
infoStage.Close()
|
|
return Result{}, false, fmt.Errorf("gamedata: write staged info: %w", err)
|
|
}
|
|
if err := infoStage.Sync(); err != nil {
|
|
infoStage.Close()
|
|
return Result{}, false, fmt.Errorf("gamedata: sync staged info: %w", err)
|
|
}
|
|
if err := infoStage.Close(); err != nil {
|
|
return Result{}, false, fmt.Errorf("gamedata: close staged info: %w", err)
|
|
}
|
|
|
|
archivePath := filepath.Join(release, ArchiveName)
|
|
infoPath := filepath.Join(release, InfoName)
|
|
stamp := time.Now().UTC().Format("20060102T150405.000000000Z")
|
|
archiveBackup, archiveMoved, err := preserveExisting(archivePath, stamp)
|
|
if err != nil {
|
|
return Result{}, false, err
|
|
}
|
|
infoBackup, infoMoved, err := preserveExisting(infoPath, stamp)
|
|
if err != nil {
|
|
if archiveMoved {
|
|
_ = os.Rename(archiveBackup, archivePath)
|
|
}
|
|
return Result{}, false, err
|
|
}
|
|
rollback := func() {
|
|
_ = os.Remove(archivePath)
|
|
_ = os.Remove(infoPath)
|
|
if archiveMoved {
|
|
_ = os.Rename(archiveBackup, archivePath)
|
|
}
|
|
if infoMoved {
|
|
_ = os.Rename(infoBackup, infoPath)
|
|
}
|
|
}
|
|
if err := os.Rename(archiveStagePath, archivePath); err != nil {
|
|
rollback()
|
|
return Result{}, false, fmt.Errorf("gamedata: install verified archive: %w", err)
|
|
}
|
|
keepStage = true
|
|
if err := os.Rename(infoStagePath, infoPath); err != nil {
|
|
rollback()
|
|
return Result{}, false, fmt.Errorf("gamedata: install size metadata: %w", err)
|
|
}
|
|
|
|
verified.ArchivePath = archivePath
|
|
return verified, true, nil
|
|
}
|
|
|
|
func Validate(root, version string) (Result, error) {
|
|
if root == "" || version == "" || strings.ContainsAny(version, `/\\`) {
|
|
return Result{}, fmt.Errorf("gamedata: invalid root or version")
|
|
}
|
|
release := filepath.Join(root, version, "release")
|
|
archivePath := filepath.Join(release, ArchiveName)
|
|
infoPath := filepath.Join(release, InfoName)
|
|
|
|
infoBytes, err := os.ReadFile(infoPath)
|
|
if err != nil {
|
|
return Result{}, fmt.Errorf("gamedata: read size metadata: %w", err)
|
|
}
|
|
expected, err := parseExpectedSize(infoBytes)
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
stat, err := os.Stat(archivePath)
|
|
if err != nil {
|
|
return Result{}, fmt.Errorf("gamedata: stat archive: %w", err)
|
|
}
|
|
if stat.Size() != expected {
|
|
return Result{}, fmt.Errorf("gamedata: archive size %d does not match metadata %d", stat.Size(), expected)
|
|
}
|
|
|
|
return validateArchive(archivePath, expected)
|
|
}
|
|
|
|
func validateArchive(archivePath string, expected int64) (Result, error) {
|
|
stat, err := os.Stat(archivePath)
|
|
if err != nil {
|
|
return Result{}, fmt.Errorf("gamedata: stat archive: %w", err)
|
|
}
|
|
if stat.Size() != expected {
|
|
return Result{}, fmt.Errorf("gamedata: archive size %d does not match metadata %d", stat.Size(), expected)
|
|
}
|
|
reader, err := zip.OpenReader(archivePath)
|
|
if err != nil {
|
|
return Result{}, fmt.Errorf("gamedata: open archive: %w", err)
|
|
}
|
|
defer reader.Close()
|
|
if len(reader.File) == 0 {
|
|
return Result{}, fmt.Errorf("gamedata: archive is empty")
|
|
}
|
|
|
|
var unpacked int64
|
|
for index, entry := range reader.File {
|
|
stream, err := entry.Open()
|
|
if err != nil {
|
|
return Result{}, fmt.Errorf("gamedata: open entry %d %q: %w", index, entry.Name, err)
|
|
}
|
|
read, copyErr := io.Copy(io.Discard, stream)
|
|
closeErr := stream.Close()
|
|
if copyErr != nil {
|
|
return Result{}, fmt.Errorf("gamedata: verify entry %d %q: %w", index, entry.Name, copyErr)
|
|
}
|
|
if closeErr != nil {
|
|
return Result{}, fmt.Errorf("gamedata: close entry %d %q: %w", index, entry.Name, closeErr)
|
|
}
|
|
if read != int64(entry.UncompressedSize64) {
|
|
return Result{}, fmt.Errorf("gamedata: entry %d %q size %d, expected %d", index, entry.Name, read, entry.UncompressedSize64)
|
|
}
|
|
unpacked += read
|
|
}
|
|
|
|
return Result{
|
|
ArchivePath: archivePath,
|
|
ArchiveSize: stat.Size(),
|
|
EntryCount: len(reader.File),
|
|
UncompressedSize: unpacked,
|
|
}, nil
|
|
}
|
|
|
|
func parseExpectedSize(infoBytes []byte) (int64, error) {
|
|
text := strings.TrimSpace(string(infoBytes))
|
|
expected, err := strconv.ParseInt(text, 10, 64)
|
|
if err != nil || expected <= 0 {
|
|
return 0, fmt.Errorf("gamedata: invalid size metadata %q", text)
|
|
}
|
|
return expected, nil
|
|
}
|
|
|
|
func downloadSmall(ctx context.Context, client *http.Client, url string, limit int64) ([]byte, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("gamedata: build metadata request: %w", err)
|
|
}
|
|
response, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("gamedata: download %s: %w", InfoName, err)
|
|
}
|
|
defer response.Body.Close()
|
|
if response.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("gamedata: download %s: HTTP %s", InfoName, response.Status)
|
|
}
|
|
b, err := io.ReadAll(io.LimitReader(response.Body, limit+1))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("gamedata: read %s: %w", InfoName, err)
|
|
}
|
|
if int64(len(b)) > limit {
|
|
return nil, fmt.Errorf("gamedata: %s exceeds %d bytes", InfoName, limit)
|
|
}
|
|
return b, nil
|
|
}
|
|
|
|
func downloadExact(ctx context.Context, client *http.Client, url string, target *os.File, expected int64) error {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("gamedata: build archive request: %w", err)
|
|
}
|
|
response, err := client.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("gamedata: download %s: %w", ArchiveName, err)
|
|
}
|
|
defer response.Body.Close()
|
|
if response.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("gamedata: download %s: HTTP %s", ArchiveName, response.Status)
|
|
}
|
|
written, err := io.Copy(target, io.LimitReader(response.Body, expected+1))
|
|
if err != nil {
|
|
return fmt.Errorf("gamedata: write staged archive: %w", err)
|
|
}
|
|
if written != expected {
|
|
return fmt.Errorf("gamedata: downloaded archive size %d, expected %d", written, expected)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func preserveExisting(path, stamp string) (string, bool, error) {
|
|
if _, err := os.Stat(path); err != nil {
|
|
if os.IsNotExist(err) {
|
|
return "", false, nil
|
|
}
|
|
return "", false, fmt.Errorf("gamedata: inspect existing file %q: %w", path, err)
|
|
}
|
|
backup := path + ".broken." + stamp
|
|
if err := os.Rename(path, backup); err != nil {
|
|
return "", false, fmt.Errorf("gamedata: preserve invalid file %q: %w", path, err)
|
|
}
|
|
return backup, true, nil
|
|
}
|