This commit is contained in:
2026-10-10 18:32:41 +07:00
parent 6bda622d8b
commit 4243adeae9
7 changed files with 2062 additions and 0 deletions
@@ -0,0 +1,158 @@
package game_package_service
import (
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/netip"
"net/url"
"strings"
"time"
)
// Special-use networks must not be reachable through externally supplied media URLs.
var blockedNetworks = []netip.Prefix{
netip.MustParsePrefix("0.0.0.0/8"),
netip.MustParsePrefix("100.64.0.0/10"),
netip.MustParsePrefix("192.0.0.0/24"),
netip.MustParsePrefix("192.0.2.0/24"),
netip.MustParsePrefix("192.88.99.0/24"),
netip.MustParsePrefix("198.18.0.0/15"),
netip.MustParsePrefix("198.51.100.0/24"),
netip.MustParsePrefix("203.0.113.0/24"),
netip.MustParsePrefix("240.0.0.0/4"),
netip.MustParsePrefix("2001::/23"),
netip.MustParsePrefix("2001:db8::/32"),
netip.MustParsePrefix("2002::/16"),
netip.MustParsePrefix("3fff::/20"),
}
func publicIP(ip netip.Addr) bool {
ip = ip.Unmap()
if !ip.IsGlobalUnicast() || ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.Zone() != "" {
return false
}
if ip.Is6() && !netip.MustParsePrefix("2000::/3").Contains(ip) {
return false
}
for _, network := range blockedNetworks {
if network.Contains(ip) {
return false
}
}
return true
}
func validateURL(u *url.URL) error {
if u == nil || (u.Scheme != "http" && u.Scheme != "https") || u.Hostname() == "" ||
u.User != nil || u.Fragment != "" || u.Opaque != "" || strings.Contains(u.Hostname(), "%") {
return ErrUnsafeURL
}
if ip, err := netip.ParseAddr(u.Hostname()); err == nil && !publicIP(ip) {
return ErrUnsafeURL
}
return nil
}
// dialPublic resolves once and connects to the checked IP, preventing DNS rebinding.
func dialPublic(ctx context.Context, network, address string) (net.Conn, error) {
host, port, err := net.SplitHostPort(address)
if err != nil {
return nil, ErrUnsafeURL
}
addresses, err := net.DefaultResolver.LookupNetIP(ctx, "ip", host)
if err != nil {
return nil, err
}
if len(addresses) == 0 {
return nil, ErrUnsafeURL
}
for _, ip := range addresses {
if !publicIP(ip) {
return nil, ErrUnsafeURL
}
}
dialer := net.Dialer{Timeout: 10 * time.Second}
var dialErr error
for _, ip := range addresses {
conn, err := dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
if err == nil {
return conn, nil
}
dialErr = err
}
return nil, dialErr
}
func newMediaClient() *http.Client {
return &http.Client{
Timeout: 120 * time.Second,
Transport: &http.Transport{
Proxy: nil,
DialContext: dialPublic,
DisableCompression: true,
DisableKeepAlives: true,
TLSHandshakeTimeout: 10 * time.Second,
ResponseHeaderTimeout: 30 * time.Second,
MaxResponseHeaderBytes: 64 << 10,
},
CheckRedirect: checkMediaRedirect,
}
}
func checkMediaRedirect(req *http.Request, via []*http.Request) error {
if len(via) > 5 {
return ErrUnsafeURL
}
return validateURL(req.URL)
}
func (s *GamePackageService) openMedia(ctx context.Context, ref string, openLocal OpenLocalFile) (io.ReadCloser, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
if s.fileDomain != "" && strings.HasPrefix(ref, s.fileDomain) {
ref = strings.TrimPrefix(ref, s.fileDomain)
}
u, err := url.Parse(ref)
if err != nil {
return nil, ErrUnsafeURL
}
if u.Scheme == "" && u.Host == "" && u.RawQuery == "" && u.Fragment == "" && validPath(ref) {
if openLocal == nil {
return nil, ErrMediaUnavailable
}
reader, err := openLocal(ctx, ref)
if err != nil {
return nil, errors.Join(ErrMediaUnavailable, err)
}
if reader == nil {
return nil, ErrMediaUnavailable
}
return reader, nil
}
if err := validateURL(u); err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return nil, errors.Join(ErrMediaUnavailable, err)
}
req.Header.Set("Accept-Encoding", "identity")
rsp, err := s.client.Do(req)
if err != nil {
return nil, errors.Join(ErrMediaUnavailable, err)
}
if rsp.StatusCode != http.StatusOK {
_ = rsp.Body.Close()
return nil, fmt.Errorf("%w: HTTP %d", ErrMediaUnavailable, rsp.StatusCode)
}
if rsp.ContentLength > s.limits.file {
_ = rsp.Body.Close()
return nil, ErrLimitExceeded
}
return rsp.Body, nil
}
@@ -0,0 +1,158 @@
// Package game_package_service builds and validates portable pre-game packages.
package game_package_service
import (
"context"
"errors"
"io"
"time"
"evening_detective_server/internal/modules/storytelling"
"evening_detective_server/internal/repos/game_screen_plans_repo"
)
const (
Version = 1
MaxArchiveSize int64 = 1 << 30
MaxFileSize int64 = 64 << 20
MaxJSONSize int64 = 8 << 20
MaxEntries = 4096
)
var (
ErrInvalidPackage = errors.New("некорректный пакет игры")
ErrUnsupportedVersion = errors.New("неподдерживаемая версия пакета игры")
ErrLimitExceeded = errors.New("превышен лимит пакета игры")
ErrMediaUnavailable = errors.New("не удалось получить файл пакета игры")
ErrUnsafeURL = errors.New("недопустимый адрес внешнего файла")
)
// OpenLocalFile opens an object by its original storage name. The service closes it.
// A caller can adapt IFileStorage.Get with io.NopCloser(bytes.NewReader(file.Data)).
type OpenLocalFile func(context.Context, string) (io.ReadCloser, error)
// Data is the version 1 game.json contract, independent of database row models.
type Data struct {
Game Game `json:"game"`
Scenario Scenario `json:"scenario"`
Teams []Team `json:"teams"`
Tickets []Ticket `json:"tickets"`
Playlists []Playlist `json:"playlists"`
ScreenPlan *ScreenPlan `json:"screen_plan,omitempty"`
Map []MapItem `json:"map"`
}
// Snapshot is supplied by the exporter after reading a consistent database snapshot.
// HasProgress must account for actions, acquired evidence and team answers.
type Snapshot struct {
SourceServer string
Data Data
HasProgress bool
}
type Game struct {
ID int `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Location *string `json:"location,omitempty"`
StartAt time.Time `json:"start_at"`
Status string `json:"status"`
Format string `json:"format"`
ScenarioID int `json:"scenario_id"`
PlaylistID *int `json:"playlist_id,omitempty"`
StartedAt *time.Time `json:"started_at,omitempty"`
EndedAt *time.Time `json:"ended_at,omitempty"`
}
type Scenario struct {
ID int `json:"id"`
Name string `json:"name"`
Description *string `json:"description,omitempty"`
Image string `json:"image,omitempty"`
Story *storytelling.Story `json:"story"`
AuthorID *int `json:"author_id,omitempty"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
PublishedAt *time.Time `json:"published_at,omitempty"`
PlaylistID *int `json:"playlist_id,omitempty"`
}
type Team struct {
ID int `json:"id"`
GameID int `json:"game_id"`
Name string `json:"name"`
Status string `json:"status"`
Password string `json:"password"`
CreatorID *int `json:"creator_id,omitempty"`
CaptainEmail string `json:"captain_email"`
ParticipantsCount int32 `json:"participants_count"`
ParticipationStatus string `json:"participation_status"`
ParticipationSource string `json:"participation_source"`
ParticipationAnsweredAt *time.Time `json:"participation_answered_at,omitempty"`
RegistrationDeliveryID *string `json:"registration_delivery_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
type Ticket struct {
ID int `json:"id"`
GameID int `json:"game_id"`
TokenHash []byte `json:"token_hash"`
Status string `json:"status"`
UsedAt *time.Time `json:"used_at,omitempty"`
UsedBy *int `json:"used_by,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
type Playlist struct {
ID int `json:"id"`
Name string `json:"name"`
Tracks []Track `json:"tracks"`
}
type Track struct {
ID int `json:"id"`
Name string `json:"name"`
Filename string `json:"filename"`
Position int `json:"position"`
}
type ScreenPlan struct {
GameID int `json:"game_id"`
Steps []game_screen_plans_repo.Step `json:"steps"`
CurrentStep int `json:"current_step"`
}
type MapItem struct {
GameID int `json:"game_id"`
Kind string `json:"kind"`
ItemID int `json:"item_id"`
X int `json:"x"`
Y int `json:"y"`
Hidden bool `json:"hidden"`
}
type Manifest struct {
Version int `json:"version"`
PackageID string `json:"package_id"`
CreatedAt time.Time `json:"created_at"`
SourceServer string `json:"source_server"`
GameID int `json:"game_id"`
Files []FileInfo `json:"files"`
}
// FileInfo also describes game.json, protecting its bytes from accidental changes.
type FileInfo struct {
Path string `json:"path"`
Size int64 `json:"size"`
MIME string `json:"mime"`
SHA256 string `json:"sha256"`
}
// Bundle owns a private directory. The caller must remove Dir after successful use.
type Bundle struct {
Manifest Manifest
Data Data
Dir string
Files map[string]string
}
@@ -0,0 +1,343 @@
package game_package_service
import (
"archive/zip"
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"io"
"net/http"
"os"
"sort"
"strings"
"time"
)
type packageLimits struct {
archive int64
file int64
json int64
entries int
}
// GamePackageService has no database or transport side effects.
type GamePackageService struct {
fileDomain string
client *http.Client
limits packageLimits
}
// NewGamePackageService accepts the existing public file URL prefix, or an empty prefix.
func NewGamePackageService(fileDomain string) *GamePackageService {
if fileDomain != "" {
fileDomain = strings.TrimRight(fileDomain, "/") + "/"
}
return &GamePackageService{
fileDomain: fileDomain,
client: newMediaClient(),
limits: packageLimits{
archive: MaxArchiveSize,
file: MaxFileSize,
json: MaxJSONSize,
entries: MaxEntries,
},
}
}
// Pack stages the complete archive before writing it. The caller discards output on error.
// Snapshot IDs and secrets are preserved; the input snapshot is never mutated.
func (s *GamePackageService) Pack(ctx context.Context, snapshot *Snapshot, openLocal OpenLocalFile, output io.Writer) error {
if err := ctx.Err(); err != nil {
return err
}
if snapshot == nil || output == nil || !validServer(snapshot.SourceServer) || snapshot.HasProgress {
return invalid("отсутствует исходный сервер или игра уже имеет прогресс")
}
if s.fileDomain != "" && !validServer(s.fileDomain) {
return invalid("некорректный адрес хранилища")
}
data, err := cloneData(&snapshot.Data, s.limits.json)
if err != nil {
return err
}
data.Map = portableMap(data.Map)
if err := validateData(data); err != nil {
return err
}
dir, err := os.MkdirTemp("", "game-package-pack-")
if err != nil {
return err
}
defer func() { _ = os.RemoveAll(dir) }()
archive, err := os.CreateTemp(dir, "archive-")
if err != nil {
return err
}
defer func() { _ = archive.Close() }()
zw := zip.NewWriter(&limitedWriter{writer: archive, remaining: s.limits.archive})
defer func() { _ = zw.Close() }()
manifest, err := newManifest(snapshot.SourceServer, data.Game.ID)
if err != nil {
return err
}
byRef := map[string]string{}
byPath := map[string]bool{}
var total int64
for _, ref := range mediaRefs(data) {
if *ref == "" {
continue
}
if name, ok := byRef[*ref]; ok {
*ref = name
continue
}
info, filename, err := s.stageMedia(ctx, dir, *ref, openLocal, s.limits.archive-total)
if err != nil {
return err
}
if !byPath[info.Path] {
if len(manifest.Files)+2 >= s.limits.entries {
return ErrLimitExceeded
}
if err := writeFileEntry(ctx, zw, info.Path, filename, info.Size); err != nil {
return err
}
manifest.Files = append(manifest.Files, info)
byPath[info.Path] = true
total += info.Size
}
if err := os.Remove(filename); err != nil {
return err
}
byRef[*ref] = info.Path
*ref = info.Path
}
raw, err := encodeJSON(data, s.limits.json)
if err != nil {
return err
}
info := describeBytes("game.json", "application/json", raw)
manifest.Files = append(manifest.Files, info)
total += info.Size
sort.Slice(manifest.Files, func(i, j int) bool { return manifest.Files[i].Path < manifest.Files[j].Path })
manifestRaw, err := encodeJSON(manifest, s.limits.json)
if err != nil {
return err
}
if total+int64(len(manifestRaw)) > s.limits.archive || len(manifest.Files)+1 > s.limits.entries {
return ErrLimitExceeded
}
if err := writeBytesEntry(zw, "game.json", raw); err != nil {
return err
}
if err := writeBytesEntry(zw, "manifest.json", manifestRaw); err != nil {
return err
}
if err := zw.Close(); err != nil {
return err
}
if _, err := archive.Seek(0, io.SeekStart); err != nil {
return err
}
_, err = copyLimited(ctx, output, archive, s.limits.archive)
return err
}
func newManifest(server string, gameID int) (*Manifest, error) {
var id [16]byte
if _, err := rand.Read(id[:]); err != nil {
return nil, err
}
return &Manifest{
Version: Version,
PackageID: hex.EncodeToString(id[:]),
CreatedAt: time.Now().UTC(),
SourceServer: server,
GameID: gameID,
}, nil
}
func cloneData(data *Data, limit int64) (*Data, error) {
raw, err := encodeJSON(data, limit)
if err != nil {
return nil, err
}
var clone Data
if err := decodeJSON(raw, &clone); err != nil {
return nil, err
}
return &clone, nil
}
func portableMap(items []MapItem) []MapItem {
result := make([]MapItem, 0, len(items))
for _, item := range items {
if item.Kind != "screen" {
result = append(result, item)
}
}
return result
}
func encodeJSON(value any, limit int64) ([]byte, error) {
var buffer bytes.Buffer
if err := json.NewEncoder(&limitedWriter{writer: &buffer, remaining: limit}).Encode(value); err != nil {
return nil, err
}
return buffer.Bytes(), nil
}
func decodeJSON(raw []byte, value any) error {
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.DisallowUnknownFields()
if err := decoder.Decode(value); err != nil {
return errors.Join(ErrInvalidPackage, err)
}
var extra any
if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) {
return invalid("лишние данные JSON")
}
return nil
}
func describeBytes(name, mime string, raw []byte) FileInfo {
hash := sha256.Sum256(raw)
return FileInfo{
Path: name,
Size: int64(len(raw)),
MIME: mime,
SHA256: hex.EncodeToString(hash[:]),
}
}
func writeBytesEntry(zw *zip.Writer, name string, raw []byte) error {
w, err := zw.Create(name)
if err != nil {
return err
}
_, err = w.Write(raw)
return err
}
func writeFileEntry(ctx context.Context, zw *zip.Writer, name, filename string, size int64) error {
file, err := os.Open(filename)
if err != nil {
return err
}
defer func() { _ = file.Close() }()
w, err := zw.Create(name)
if err != nil {
return err
}
n, err := copyLimited(ctx, w, file, size)
if err == nil && n != size {
err = invalid("размер файла изменился")
}
return err
}
type limitedWriter struct {
writer io.Writer
remaining int64
}
func (w *limitedWriter) Write(data []byte) (int, error) {
if int64(len(data)) > w.remaining {
return 0, ErrLimitExceeded
}
n, err := w.writer.Write(data)
w.remaining -= int64(n)
return n, err
}
type contextReader struct {
ctx context.Context
reader io.Reader
}
func (r *contextReader) Read(data []byte) (int, error) {
if err := r.ctx.Err(); err != nil {
return 0, err
}
return r.reader.Read(data)
}
func copyLimited(ctx context.Context, dst io.Writer, src io.Reader, limit int64) (int64, error) {
if limit < 0 {
return 0, ErrLimitExceeded
}
n, err := io.CopyBuffer(dst, io.LimitReader(&contextReader{ctx: ctx, reader: src}, limit+1), make([]byte, 32<<10))
if err != nil {
return n, err
}
if n > limit {
return n, ErrLimitExceeded
}
return n, ctx.Err()
}
func (s *GamePackageService) stageMedia(ctx context.Context, dir, ref string, openLocal OpenLocalFile, remaining int64) (FileInfo, string, error) {
r, err := s.openMedia(ctx, ref, openLocal)
if err != nil {
return FileInfo{}, "", err
}
defer func() { _ = r.Close() }()
file, err := os.CreateTemp(dir, "media-")
if err != nil {
return FileInfo{}, "", err
}
defer func() { _ = file.Close() }()
hash := sha256.New()
n, err := copyLimited(ctx, io.MultiWriter(file, hash), r, min(s.limits.file, remaining))
if err != nil {
return FileInfo{}, "", err
}
if n == 0 {
return FileInfo{}, "", invalid("пустой медиафайл")
}
if _, err := file.Seek(0, io.SeekStart); err != nil {
return FileInfo{}, "", err
}
header := make([]byte, 512)
nread, err := file.Read(header)
if err != nil && !errors.Is(err, io.EOF) {
return FileInfo{}, "", err
}
mime, ext := mediaType(header[:nread])
if ext == "" {
return FileInfo{}, "", invalid("неподдерживаемое содержимое медиафайла")
}
digest := hex.EncodeToString(hash.Sum(nil))
return FileInfo{
Path: "files/" + digest + ext,
Size: n,
MIME: mime,
SHA256: digest,
}, file.Name(), nil
}
func mediaType(header []byte) (string, string) {
mime := http.DetectContentType(header)
if len(header) >= 4 && header[0] == 0xff && header[1]&0xe0 == 0xe0 &&
header[1]&0x18 != 0x08 && header[1]&0x06 != 0 && header[2]&0xf0 != 0 && header[2]&0xf0 != 0xf0 && header[2]&0x0c != 0x0c {
mime = "audio/mpeg"
}
extensions := map[string]string{
"application/pdf": ".pdf",
"image/jpeg": ".jpg",
"image/png": ".png",
"image/gif": ".gif",
"image/webp": ".webp",
"audio/mpeg": ".mp3",
"application/ogg": ".ogg",
"audio/wave": ".wav",
"audio/x-wav": ".wav",
"video/mp4": ".mp4",
"video/webm": ".webm",
}
return mime, extensions[mime]
}
@@ -0,0 +1,800 @@
package game_package_service
import (
"archive/zip"
"bytes"
"context"
"encoding/binary"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"net/netip"
"net/url"
"os"
"reflect"
"strconv"
"strings"
"testing"
"time"
"evening_detective_server/internal/modules/storytelling"
"evening_detective_server/internal/repos/game_screen_plans_repo"
)
func fixture() (*Snapshot, map[string][]byte) {
now := time.Date(2026, 10, 10, 12, 30, 0, 0, time.UTC)
user, playlistID := 7, 30
return &Snapshot{
SourceServer: "https://game.example",
Data: Data{
Game: Game{ID: 10, Name: "Game", StartAt: now, Status: "draft", Format: "offline", ScenarioID: 20, PlaylistID: &playlistID},
Scenario: Scenario{
ID: 20, Name: "Story", Image: "cover.png", AuthorID: &user, Status: "public", CreatedAt: now, UpdatedAt: now,
Story: &storytelling.Story{
Background: "cover.png",
Introduction: &storytelling.Introduction{Text: "Intro", Audio: "music.mp3"},
Conclusion: &storytelling.Conclusion{Text: "End", Video: "video.webm"},
Places: []*storytelling.Place{{Code: "p", Name: "Place", Image: "cover.png", Applications: []*storytelling.Application{{Name: "Evidence", Image: "evidence.pdf", FileType: "pdf"}}}},
Questions: []*storytelling.Question{{Code: "q", Text: "Question", Answer: "Secret answer"}},
},
},
Teams: []Team{
{ID: 40, GameID: 10, Name: "Confirmed", Status: "register", Password: "original-qr-password", CaptainEmail: "captain@example.org", ParticipantsCount: 4, ParticipationStatus: "confirmed", CreatorID: &user},
{ID: 41, GameID: 10, Name: "Cancelled", Status: "register", Password: "cancelled-password", ParticipationStatus: "cancelled"},
},
Tickets: []Ticket{
{ID: 50, GameID: 10, TokenHash: bytes.Repeat([]byte{1}, 32), Status: "active", CreatedAt: now},
{ID: 51, GameID: 10, TokenHash: bytes.Repeat([]byte{2}, 32), Status: "used", UsedAt: &now, UsedBy: &user, CreatedAt: now},
},
Playlists: []Playlist{{ID: 30, Name: "Music", Tracks: []Track{{ID: 60, Name: "Track", Filename: "music.mp3", Position: 0}}}},
ScreenPlan: &ScreenPlan{GameID: 10, CurrentStep: 1, Steps: []game_screen_plans_repo.Step{{Mode: 1}, {Mode: 7, TimerDurationSeconds: 30, TimerLabel: "Break"}}},
Map: []MapItem{{GameID: 10, Kind: "team", ItemID: 40, X: 123, Y: 456}, {GameID: 10, Kind: "organizer_desk", ItemID: 1, X: 80, Y: 90}},
},
}, map[string][]byte{
"cover.png": []byte("\x89PNG\r\n\x1a\nfixture-image"),
"music.mp3": []byte("ID3\x04\x00\x00fixture-music"),
"video.webm": []byte("\x1a\x45\xdf\xa3fixture-video"),
"evidence.pdf": []byte("%PDF-1.7\nfixture-evidence"),
}
}
func localFiles(files map[string][]byte, calls map[string]int) OpenLocalFile {
return func(ctx context.Context, name string) (io.ReadCloser, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
if calls != nil {
calls[name]++
}
data, ok := files[name]
if !ok {
return nil, os.ErrNotExist
}
return io.NopCloser(bytes.NewReader(data)), nil
}
}
func buildPackage(t *testing.T, service *GamePackageService, snapshot *Snapshot, files map[string][]byte) []byte {
t.Helper()
var archive bytes.Buffer
if err := service.Pack(context.Background(), snapshot, localFiles(files, nil), &archive); err != nil {
t.Fatal(err)
}
return archive.Bytes()
}
func TestPackagePreservesPregameData(t *testing.T) {
for _, tc := range []struct{ name, format string }{{"offline", "offline"}, {"online", "online"}} {
t.Run(tc.name, func(t *testing.T) {
snapshot, files := fixture()
snapshot.Data.Game.Format = tc.format
snapshot.Data.Map = append(snapshot.Data.Map, MapItem{GameID: 10, Kind: "screen", ItemID: 99})
before, _ := json.Marshal(snapshot)
calls := map[string]int{}
service := NewGamePackageService("")
var archive bytes.Buffer
if err := service.Pack(context.Background(), snapshot, localFiles(files, calls), &archive); err != nil {
t.Fatal(err)
}
after, _ := json.Marshal(snapshot)
if !bytes.Equal(before, after) {
t.Fatal("Pack mutated input")
}
// Unpack must not consult any downloader, including for originally external files.
service.client.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) { t.Fatal("network during Unpack"); return nil, nil })
bundle, err := service.Unpack(context.Background(), bytes.NewReader(archive.Bytes()), int64(archive.Len()), t.TempDir())
if err != nil {
t.Fatal(err)
}
defer func() { _ = os.RemoveAll(bundle.Dir) }()
if !reflect.DeepEqual(bundle.Data.Game, snapshot.Data.Game) || !reflect.DeepEqual(bundle.Data.Teams, snapshot.Data.Teams) ||
!reflect.DeepEqual(bundle.Data.Tickets, snapshot.Data.Tickets) || !reflect.DeepEqual(bundle.Data.ScreenPlan, snapshot.Data.ScreenPlan) ||
!reflect.DeepEqual(bundle.Data.Map, snapshot.Data.Map[:2]) {
t.Fatal("game, QR credentials, ticket hashes or settings changed")
}
if bundle.Data.Scenario.Story.Questions[0].Answer != "Secret answer" || bundle.Manifest.GameID != 10 || bundle.Manifest.SourceServer != snapshot.SourceServer {
t.Fatal("scenario or origin changed")
}
if len(bundle.Files) != len(files) || len(bundle.Manifest.Files) != len(files)+1 {
t.Fatal("incorrect media count")
}
for _, count := range calls {
if count != 1 {
t.Fatal("duplicate file fetched")
}
}
wantRefs := []string{"cover.png", "cover.png", "music.mp3", "video.webm", "cover.png", "evidence.pdf", "music.mp3"}
for i, ref := range mediaRefs(&bundle.Data) {
content, err := os.ReadFile(bundle.Files[*ref])
if err != nil || !bytes.Equal(content, files[wantRefs[i]]) {
t.Fatalf("media %d did not survive round trip: %v", i, err)
}
}
mode, err := os.Stat(bundle.Dir)
if err != nil || mode.Mode().Perm() != 0700 {
t.Fatal("bundle directory is not private")
}
for _, filename := range bundle.Files {
mode, err := os.Stat(filename)
if err != nil || mode.Mode().Perm() != 0600 {
t.Fatal("extracted file is not private")
}
}
})
}
}
func TestPackRejectsInvalidSnapshot(t *testing.T) {
for _, tc := range []struct {
name string
change func(*Snapshot)
}{
{"progress", func(s *Snapshot) { s.HasProgress = true }},
{"running", func(s *Snapshot) { s.Data.Game.Status = "run" }},
{"paused", func(s *Snapshot) { s.Data.Game.Status = "pause" }},
{"finished", func(s *Snapshot) { s.Data.Game.Status = "finish" }},
{"started draft", func(s *Snapshot) { s.Data.Game.StartedAt = &s.Data.Game.StartAt }},
{"ended draft", func(s *Snapshot) { s.Data.Game.EndedAt = &s.Data.Game.StartAt }},
{"origin", func(s *Snapshot) { s.SourceServer = "file:///tmp" }},
{"scenario link", func(s *Snapshot) { s.Data.Game.ScenarioID++ }},
{"nil story", func(s *Snapshot) { s.Data.Scenario.Story = nil }},
{"nil place", func(s *Snapshot) { s.Data.Scenario.Story.Places[0] = nil }},
{"nil application", func(s *Snapshot) { s.Data.Scenario.Story.Places[0].Applications[0] = nil }},
{"nil question", func(s *Snapshot) { s.Data.Scenario.Story.Questions[0] = nil }},
{"nil door", func(s *Snapshot) { s.Data.Scenario.Story.Places[0].Doors = []*storytelling.Door{nil} }},
{"nil key", func(s *Snapshot) { s.Data.Scenario.Story.Places[0].Keys = []*storytelling.Key{nil} }},
{"nil door key", func(s *Snapshot) {
s.Data.Scenario.Story.Places[0].Doors = []*storytelling.Door{{Code: "p", Keys: []*storytelling.Key{nil}}}
}},
{"missing door target", func(s *Snapshot) { s.Data.Scenario.Story.Places[0].Doors = []*storytelling.Door{{Code: "missing"}} }},
{"duplicate place code", func(s *Snapshot) {
s.Data.Scenario.Story.Places = append(s.Data.Scenario.Story.Places, s.Data.Scenario.Story.Places[0])
}},
{"duplicate team", func(s *Snapshot) { s.Data.Teams = append(s.Data.Teams, s.Data.Teams[0]) }},
{"missing QR", func(s *Snapshot) { s.Data.Teams[0].Password = "" }},
{"foreign team", func(s *Snapshot) { s.Data.Teams[0].GameID++ }},
{"participation", func(s *Snapshot) { s.Data.Teams[0].ParticipationStatus = "other" }},
{"duplicate ticket", func(s *Snapshot) { s.Data.Tickets = append(s.Data.Tickets, s.Data.Tickets[0]) }},
{"short ticket hash", func(s *Snapshot) { s.Data.Tickets[0].TokenHash = []byte{1} }},
{"duplicate ticket hash", func(s *Snapshot) { s.Data.Tickets[1].TokenHash = s.Data.Tickets[0].TokenHash }},
{"active used ticket", func(s *Snapshot) { s.Data.Tickets[0].UsedAt = &s.Data.Game.StartAt }},
{"used ticket without time", func(s *Snapshot) { s.Data.Tickets[1].UsedAt = nil }},
{"missing playlist", func(s *Snapshot) { s.Data.Playlists = nil }},
{"duplicate track", func(s *Snapshot) {
s.Data.Playlists[0].Tracks = append(s.Data.Playlists[0].Tracks, s.Data.Playlists[0].Tracks[0])
}},
{"foreign map team", func(s *Snapshot) { s.Data.Map[0].ItemID = 99 }},
{"foreign map game", func(s *Snapshot) { s.Data.Map[0].GameID++ }},
{"duplicate map item", func(s *Snapshot) { s.Data.Map = append(s.Data.Map, s.Data.Map[0]) }},
{"invalid organizer desk", func(s *Snapshot) { s.Data.Map[1].ItemID = 0 }},
{"invalid plan index", func(s *Snapshot) { s.Data.ScreenPlan.CurrentStep = 10 }},
{"invalid plan mode", func(s *Snapshot) { s.Data.ScreenPlan.Steps[0].Mode = 9 }},
} {
t.Run(tc.name, func(t *testing.T) {
snapshot, files := fixture()
tc.change(snapshot)
var output bytes.Buffer
err := NewGamePackageService("").Pack(context.Background(), snapshot, localFiles(files, nil), &output)
if !errors.Is(err, ErrInvalidPackage) || output.Len() != 0 {
t.Fatalf("got %v, output %d", err, output.Len())
}
})
}
}
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
func TestPackExternalMediaAndLocalURLs(t *testing.T) {
for _, tc := range []struct {
name, ref string
external bool
}{
{"external", "https://media.example/image", true},
{"storage URL", "https://storage.example/api/files/cover.png", false},
} {
t.Run(tc.name, func(t *testing.T) {
snapshot, files := fixture()
snapshot.Data.Scenario.Image = tc.ref
snapshot.Data.Scenario.Story.Background = tc.ref
service := NewGamePackageService("https://storage.example/api/files/")
calls := 0
service.client.Transport = roundTripFunc(func(r *http.Request) (*http.Response, error) {
calls++
if r.URL.String() != tc.ref || r.Header.Get("Accept-Encoding") != "identity" {
t.Fatal("unexpected request")
}
return &http.Response{StatusCode: 200, Body: io.NopCloser(bytes.NewReader(files["cover.png"])), ContentLength: int64(len(files["cover.png"]))}, nil
})
archive := buildPackage(t, service, snapshot, files)
if (calls == 1) != tc.external || calls > 1 {
t.Fatalf("external fetches: %d", calls)
}
bundle, err := service.Unpack(context.Background(), bytes.NewReader(archive), int64(len(archive)), t.TempDir())
if err != nil {
t.Fatal(err)
}
defer func() { _ = os.RemoveAll(bundle.Dir) }()
if len(bundle.Files) != len(files) {
t.Fatal("identical content was not deduplicated")
}
})
}
}
func TestMediaRequestsRejectUnsafeAddresses(t *testing.T) {
for _, tc := range []struct {
address string
safe bool
}{
{"https://example.org/media", true}, {"http://8.8.8.8/media", true},
{"http://127.0.0.1/x", false}, {"http://10.0.0.1/x", false}, {"http://192.168.10.155/x", false},
{"http://169.254.169.254/x", false}, {"http://[::1]/x", false}, {"http://[::ffff:127.0.0.1]/x", false},
{"http://[64:ff9b::7f00:1]/x", false}, {"https://user:password@example.org/x", false},
{"file:///tmp/x", false}, {"//example.org/x", false}, {"https://example.org/x#fragment", false},
} {
t.Run(tc.address, func(t *testing.T) {
u, err := url.Parse(tc.address)
if err != nil {
t.Fatal(err)
}
err = validateURL(u)
if tc.safe && err != nil || !tc.safe && !errors.Is(err, ErrUnsafeURL) {
t.Fatalf("got %v", err)
}
})
}
for _, host := range []string{"127.0.0.1:80", "localhost:80", "[::1]:80", "192.168.10.155:443"} {
t.Run(host, func(t *testing.T) {
conn, err := dialPublic(context.Background(), "tcp", host)
if conn != nil {
_ = conn.Close()
t.Fatal("unsafe connection")
}
if !errors.Is(err, ErrUnsafeURL) {
t.Fatalf("got %v", err)
}
})
}
}
func TestPublicIPRejectsSpecialNetworks(t *testing.T) {
for _, tc := range []struct {
ip string
safe bool
}{
{"8.8.8.8", true}, {"2606:4700:4700::1111", true}, {"::ffff:8.8.8.8", true},
{"0.1.2.3", false}, {"100.64.0.1", false}, {"192.0.0.1", false}, {"192.0.2.1", false},
{"198.18.0.1", false}, {"198.51.100.1", false}, {"203.0.113.1", false}, {"240.0.0.1", false},
{"224.0.0.1", false}, {"2001:db8::1", false}, {"2002:7f00:1::", false}, {"3fff::1", false},
{"fc00::1", false}, {"fe80::1", false}, {"ff02::1", false},
} {
t.Run(tc.ip, func(t *testing.T) {
if publicIP(netip.MustParseAddr(tc.ip)) != tc.safe {
t.Fatal("incorrect address classification")
}
})
}
}
func TestMediaRedirects(t *testing.T) {
for _, tc := range []struct {
name, target string
hops int
want error
}{
{"public redirect", "https://example.org/x", 1, nil},
{"fifth redirect", "https://example.org/x", 5, nil},
{"sixth redirect", "https://example.org/x", 6, ErrUnsafeURL},
{"LAN redirect", "http://192.168.10.1/x", 1, ErrUnsafeURL},
} {
t.Run(tc.name, func(t *testing.T) {
u, _ := url.Parse(tc.target)
err := checkMediaRedirect(&http.Request{URL: u}, make([]*http.Request, tc.hops))
if !errors.Is(err, tc.want) {
t.Fatalf("got %v", err)
}
})
}
for _, tc := range []struct {
name string
redirect bool
}{{"direct loopback", false}, {"loopback redirect", true}} {
t.Run(tc.name, func(t *testing.T) {
hits := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { hits++; w.WriteHeader(200) }))
defer server.Close()
service := NewGamePackageService("")
ref := server.URL
if tc.redirect {
ref = "http://media.example/x"
service.client.Transport = roundTripFunc(func(r *http.Request) (*http.Response, error) {
return &http.Response{StatusCode: 302, Header: http.Header{"Location": {server.URL}}, Body: io.NopCloser(strings.NewReader(""))}, nil
})
}
r, err := service.openMedia(context.Background(), ref, nil)
if r != nil {
_ = r.Close()
}
if !errors.Is(err, ErrUnsafeURL) || hits != 0 {
t.Fatalf("got %v, hits %d", err, hits)
}
})
}
}
func TestPackLimitsErrorsAndCleanup(t *testing.T) {
for _, tc := range []struct {
name string
setup func(*GamePackageService, *Snapshot, map[string][]byte)
want error
}{
{"file limit", func(s *GamePackageService, _ *Snapshot, _ map[string][]byte) { s.limits.file = 5 }, ErrLimitExceeded},
{"JSON limit", func(s *GamePackageService, _ *Snapshot, _ map[string][]byte) { s.limits.json = 5 }, ErrLimitExceeded},
{"ZIP limit", func(s *GamePackageService, _ *Snapshot, _ map[string][]byte) { s.limits.archive = 100 }, ErrLimitExceeded},
{"entry limit", func(s *GamePackageService, _ *Snapshot, _ map[string][]byte) { s.limits.entries = 3 }, ErrLimitExceeded},
{"missing local", func(_ *GamePackageService, _ *Snapshot, files map[string][]byte) { delete(files, "music.mp3") }, ErrMediaUnavailable},
{"empty file", func(_ *GamePackageService, _ *Snapshot, files map[string][]byte) { files["music.mp3"] = nil }, ErrInvalidPackage},
{"HTML as media", func(_ *GamePackageService, _ *Snapshot, files map[string][]byte) {
files["music.mp3"] = []byte("<html>not music</html>")
}, ErrInvalidPackage},
{"unsafe ref", func(_ *GamePackageService, snap *Snapshot, _ map[string][]byte) {
snap.Data.Scenario.Image = "../secret"
}, ErrUnsafeURL},
{"HTTP failure", func(s *GamePackageService, snap *Snapshot, _ map[string][]byte) {
snap.Data.Scenario.Image = "https://media.example/x"
s.client.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{StatusCode: 404, Body: io.NopCloser(strings.NewReader(""))}, nil
})
}, ErrMediaUnavailable},
{"HTTP declared size", func(s *GamePackageService, snap *Snapshot, _ map[string][]byte) {
snap.Data.Scenario.Image = "https://media.example/x"
s.client.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{StatusCode: 200, ContentLength: MaxFileSize + 1, Body: io.NopCloser(strings.NewReader(""))}, nil
})
}, ErrLimitExceeded},
{"HTTP actual size", func(s *GamePackageService, snap *Snapshot, _ map[string][]byte) {
snap.Data.Scenario.Image = "https://media.example/x"
s.limits.file = 16
s.client.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{StatusCode: 200, ContentLength: -1, Body: io.NopCloser(strings.NewReader(strings.Repeat("x", 17)))}, nil
})
}, ErrLimitExceeded},
} {
t.Run(tc.name, func(t *testing.T) {
root := t.TempDir()
t.Setenv("TMPDIR", root)
service := NewGamePackageService("")
snapshot, files := fixture()
tc.setup(service, snapshot, files)
var output bytes.Buffer
err := service.Pack(context.Background(), snapshot, localFiles(files, nil), &output)
if !errors.Is(err, tc.want) || output.Len() != 0 {
t.Fatalf("got %v, output %d", err, output.Len())
}
entries, err := os.ReadDir(root)
if err != nil || len(entries) != 0 {
t.Fatal("temporary files leaked")
}
})
}
}
func TestPackageCancellation(t *testing.T) {
for _, tc := range []struct {
name string
during bool
}{{"before pack", false}, {"during local read", true}} {
t.Run(tc.name, func(t *testing.T) {
root := t.TempDir()
t.Setenv("TMPDIR", root)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
snapshot, files := fixture()
open := localFiles(files, nil)
if tc.during {
open = func(context.Context, string) (io.ReadCloser, error) {
cancel()
return io.NopCloser(strings.NewReader("unused")), nil
}
} else {
cancel()
}
var output bytes.Buffer
err := NewGamePackageService("").Pack(ctx, snapshot, open, &output)
if !errors.Is(err, context.Canceled) || output.Len() != 0 {
t.Fatalf("got %v", err)
}
entries, _ := os.ReadDir(root)
if len(entries) != 0 {
t.Fatal("temporary files leaked")
}
})
}
}
type zipEntry struct {
name string
body []byte
mode os.FileMode
}
func rewriteArchive(t *testing.T, raw []byte, change func([]zipEntry) []zipEntry) []byte {
t.Helper()
zr, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw)))
if err != nil {
t.Fatal(err)
}
entries := []zipEntry{}
for _, f := range zr.File {
r, err := f.Open()
if err != nil {
t.Fatal(err)
}
body, err := io.ReadAll(r)
_ = r.Close()
if err != nil {
t.Fatal(err)
}
entries = append(entries, zipEntry{name: f.Name, body: body, mode: f.Mode()})
}
var output bytes.Buffer
zw := zip.NewWriter(&output)
for _, entry := range change(entries) {
header := &zip.FileHeader{Name: entry.name, Method: zip.Deflate}
header.SetMode(entry.mode)
w, err := zw.CreateHeader(header)
if err != nil {
t.Fatal(err)
}
if _, err := w.Write(entry.body); err != nil {
t.Fatal(err)
}
}
if err := zw.Close(); err != nil {
t.Fatal(err)
}
return output.Bytes()
}
func changeManifest(entries []zipEntry, change func(*Manifest)) []zipEntry {
for i := range entries {
if entries[i].name == "manifest.json" {
var manifest Manifest
_ = json.Unmarshal(entries[i].body, &manifest)
change(&manifest)
entries[i].body, _ = json.Marshal(manifest)
}
}
return entries
}
func changeGame(entries []zipEntry, change func(*Data)) []zipEntry {
var updated FileInfo
for i := range entries {
if entries[i].name == "game.json" {
var data Data
_ = json.Unmarshal(entries[i].body, &data)
change(&data)
entries[i].body, _ = json.Marshal(data)
updated = describeBytes("game.json", "application/json", entries[i].body)
}
}
return changeManifest(entries, func(m *Manifest) {
for i := range m.Files {
if m.Files[i].Path == "game.json" {
m.Files[i] = updated
}
}
})
}
func TestUnpackRejectsDamagedPackages(t *testing.T) {
snapshot, files := fixture()
raw := buildPackage(t, NewGamePackageService(""), snapshot, files)
for _, tc := range []struct {
name string
change func([]zipEntry) []zipEntry
want error
}{
{"version", func(e []zipEntry) []zipEntry { return changeManifest(e, func(m *Manifest) { m.Version++ }) }, ErrUnsupportedVersion},
{"wrong game ID", func(e []zipEntry) []zipEntry { return changeManifest(e, func(m *Manifest) { m.GameID++ }) }, ErrInvalidPackage},
{"bad MIME", func(e []zipEntry) []zipEntry {
return changeManifest(e, func(m *Manifest) { m.Files[0].MIME = "text/html" })
}, ErrInvalidPackage},
{"invalid game relation", func(e []zipEntry) []zipEntry { return changeGame(e, func(d *Data) { d.Teams[0].GameID++ }) }, ErrInvalidPackage},
{"missing referenced file", func(e []zipEntry) []zipEntry {
return changeGame(e, func(d *Data) { d.Scenario.Image = "files/missing.png" })
}, ErrInvalidPackage},
{"external reference", func(e []zipEntry) []zipEntry {
return changeGame(e, func(d *Data) { d.Scenario.Image = "https://example.org/x" })
}, ErrInvalidPackage},
{"unreferenced file", func(e []zipEntry) []zipEntry {
return changeGame(e, func(d *Data) { d.Scenario.Story.Conclusion.Video = "" })
}, ErrInvalidPackage},
{"nil question in archive", func(e []zipEntry) []zipEntry {
return changeGame(e, func(d *Data) { d.Scenario.Story.Questions[0] = nil })
}, ErrInvalidPackage},
{"bad SHA", func(e []zipEntry) []zipEntry {
return changeManifest(e, func(m *Manifest) { m.Files[0].SHA256 = strings.Repeat("0", 64) })
}, ErrInvalidPackage},
{"bad size", func(e []zipEntry) []zipEntry { return changeManifest(e, func(m *Manifest) { m.Files[0].Size++ }) }, ErrInvalidPackage},
{"duplicate manifest path", func(e []zipEntry) []zipEntry { return changeManifest(e, func(m *Manifest) { m.Files[1] = m.Files[0] }) }, ErrInvalidPackage},
{"missing media", func(e []zipEntry) []zipEntry { return e[1:] }, ErrInvalidPackage},
{"duplicate ZIP path", func(e []zipEntry) []zipEntry { return append(e, e[0]) }, ErrInvalidPackage},
{"undeclared file", func(e []zipEntry) []zipEntry {
return append(e, zipEntry{name: "files/extra.png", body: []byte("extra"), mode: 0600})
}, ErrInvalidPackage},
{"traversal", func(e []zipEntry) []zipEntry { e[0].name = "../escape.png"; return e }, ErrInvalidPackage},
{"absolute", func(e []zipEntry) []zipEntry { e[0].name = "/escape.png"; return e }, ErrInvalidPackage},
{"backslash", func(e []zipEntry) []zipEntry { e[0].name = "files\\escape.png"; return e }, ErrInvalidPackage},
{"symlink", func(e []zipEntry) []zipEntry { e[0].mode = os.ModeSymlink | 0600; return e }, ErrInvalidPackage},
{"directory", func(e []zipEntry) []zipEntry { e[0].mode = os.ModeDir | 0700; return e }, ErrInvalidPackage},
{"missing manifest", func(e []zipEntry) []zipEntry { return e[:len(e)-1] }, ErrInvalidPackage},
{"changed media", func(e []zipEntry) []zipEntry { e[0].body[0] ^= 1; return e }, ErrInvalidPackage},
{"changed game", func(e []zipEntry) []zipEntry {
for i := range e {
if e[i].name == "game.json" {
e[i].body = append(e[i].body, ' ')
}
}
return e
}, ErrInvalidPackage},
} {
t.Run(tc.name, func(t *testing.T) {
archive := rewriteArchive(t, raw, tc.change)
parent := t.TempDir()
bundle, err := NewGamePackageService("").Unpack(context.Background(), bytes.NewReader(archive), int64(len(archive)), parent)
if bundle != nil || !errors.Is(err, tc.want) {
t.Fatalf("got %v", err)
}
entries, _ := os.ReadDir(parent)
if len(entries) != 0 {
t.Fatal("failed Unpack leaked files")
}
})
}
}
func TestUnpackLimitsAndCancellation(t *testing.T) {
snapshot, files := fixture()
raw := buildPackage(t, NewGamePackageService(""), snapshot, files)
for _, tc := range []struct {
name string
setup func(*GamePackageService)
canceled bool
want error
}{
{"archive limit", func(s *GamePackageService) { s.limits.archive = int64(len(raw)) - 1 }, false, ErrLimitExceeded},
{"file limit", func(s *GamePackageService) { s.limits.file = 5 }, false, ErrLimitExceeded},
{"JSON limit", func(s *GamePackageService) { s.limits.json = 5 }, false, ErrLimitExceeded},
{"entry limit", func(s *GamePackageService) { s.limits.entries = 2 }, false, ErrLimitExceeded},
{"cancellation", func(*GamePackageService) {}, true, context.Canceled},
} {
t.Run(tc.name, func(t *testing.T) {
service := NewGamePackageService("")
tc.setup(service)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if tc.canceled {
cancel()
}
bundle, err := service.Unpack(ctx, bytes.NewReader(raw), int64(len(raw)), t.TempDir())
if bundle != nil || !errors.Is(err, tc.want) {
t.Fatalf("got %v", err)
}
})
}
for _, tc := range []struct {
name string
mutate func([]byte)
want error
}{
{"false entry count", func(b []byte) {
binary.LittleEndian.PutUint16(b[len(b)-12:len(b)-10], 1)
binary.LittleEndian.PutUint16(b[len(b)-14:len(b)-12], 1)
}, ErrInvalidPackage},
{"ZIP64 marker", func(b []byte) {
binary.LittleEndian.PutUint16(b[len(b)-12:len(b)-10], 65535)
binary.LittleEndian.PutUint16(b[len(b)-14:len(b)-12], 65535)
}, ErrInvalidPackage},
} {
t.Run(tc.name, func(t *testing.T) {
b := bytes.Clone(raw)
tc.mutate(b)
_, err := NewGamePackageService("").Unpack(context.Background(), bytes.NewReader(b), int64(len(b)), t.TempDir())
if !errors.Is(err, tc.want) {
t.Fatalf("got %v", err)
}
})
}
}
// A generated source and a checking writer detect accidental ReadAll of large media.
type generatedMedia struct {
remaining int64
prefix []byte
largestRead int
}
func (r *generatedMedia) Read(data []byte) (int, error) {
if len(data) > r.largestRead {
r.largestRead = len(data)
}
if r.remaining == 0 {
return 0, io.EOF
}
n := int(min(int64(len(data)), r.remaining))
clear(data[:n])
if len(r.prefix) > 0 {
used := copy(data[:n], r.prefix)
r.prefix = r.prefix[used:]
}
r.remaining -= int64(n)
return n, nil
}
func (*generatedMedia) Close() error { return nil }
func TestPackStreamsMedia(t *testing.T) {
for _, size := range []int64{1 << 20, 16 << 20} {
t.Run(strconv.FormatInt(size>>20, 10)+"MiB", func(t *testing.T) {
snapshot, files := fixture()
source := &generatedMedia{remaining: size, prefix: files["music.mp3"]}
fallback := localFiles(files, nil)
open := func(ctx context.Context, name string) (io.ReadCloser, error) {
if name == "music.mp3" {
return source, nil
}
return fallback(ctx, name)
}
if err := NewGamePackageService("").Pack(context.Background(), snapshot, open, io.Discard); err != nil {
t.Fatal(err)
}
if source.largestRead > 32<<10 || source.remaining != 0 {
t.Fatal("media was not streamed")
}
})
}
}
func TestMediaClientPolicy(t *testing.T) {
for _, name := range []string{"default"} {
t.Run(name, func(t *testing.T) {
client := newMediaClient()
transport := client.Transport.(*http.Transport)
if client.Timeout != 120*time.Second || transport.Proxy != nil || !transport.DisableCompression || transport.DialContext == nil {
t.Fatal("unsafe downloader defaults")
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
conn, err := transport.DialContext(ctx, "tcp", "8.8.8.8:443")
if conn != nil {
_ = conn.Close()
}
if !errors.Is(err, context.Canceled) {
t.Fatalf("got %v", err)
}
})
}
}
type cancelReaderAt struct {
reader io.ReaderAt
offset int64
cancel context.CancelFunc
}
func (r cancelReaderAt) ReadAt(data []byte, offset int64) (int, error) {
n, err := r.reader.ReadAt(data, offset)
if offset == r.offset {
r.cancel()
}
return n, err
}
func TestUnpackCancellationDuringExtraction(t *testing.T) {
for _, name := range []string{"media extraction"} {
t.Run(name, func(t *testing.T) {
snapshot, files := fixture()
raw := buildPackage(t, NewGamePackageService(""), snapshot, files)
zr, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw)))
if err != nil {
t.Fatal(err)
}
offset, err := zr.File[0].DataOffset()
if err != nil {
t.Fatal(err)
}
parent := t.TempDir()
marker := parent + "/keep"
if err := os.WriteFile(marker, []byte("keep"), 0600); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
reader := cancelReaderAt{reader: bytes.NewReader(raw), offset: offset, cancel: cancel}
bundle, err := NewGamePackageService("").Unpack(ctx, reader, int64(len(raw)), parent)
if bundle != nil || !errors.Is(err, context.Canceled) {
t.Fatalf("got %v", err)
}
entries, err := os.ReadDir(parent)
if err != nil || len(entries) != 1 || entries[0].Name() != "keep" {
t.Fatal("failed cleanup or parent modified")
}
})
}
}
func TestPackagePreservesBothPlaylistAssignments(t *testing.T) {
for _, name := range []string{"game override and scenario default"} {
t.Run(name, func(t *testing.T) {
snapshot, files := fixture()
defaultID := 31
snapshot.Data.Scenario.PlaylistID = &defaultID
snapshot.Data.Playlists = append(snapshot.Data.Playlists, Playlist{
ID: defaultID,
Name: "Default music",
Tracks: []Track{{ID: 61, Name: "Default track", Filename: "music.mp3", Position: 5}},
})
service := NewGamePackageService("")
raw := buildPackage(t, service, snapshot, files)
bundle, err := service.Unpack(context.Background(), bytes.NewReader(raw), int64(len(raw)), t.TempDir())
if err != nil {
t.Fatal(err)
}
defer func() { _ = os.RemoveAll(bundle.Dir) }()
if *bundle.Data.Game.PlaylistID != 30 || *bundle.Data.Scenario.PlaylistID != defaultID || len(bundle.Data.Playlists) != 2 ||
bundle.Data.Playlists[1].Tracks[0].Position != 5 {
t.Fatal("playlist assignments changed")
}
})
}
}
func TestRawSizeLimitCountsJSONAndMedia(t *testing.T) {
for _, name := range []string{"highly compressed media"} {
t.Run(name, func(t *testing.T) {
snapshot, files := fixture()
files["music.mp3"] = append(files["music.mp3"], make([]byte, 64<<10)...)
service := NewGamePackageService("")
raw := buildPackage(t, service, snapshot, files)
service.limits.archive = 32 << 10
if len(raw) >= int(service.limits.archive) {
t.Fatal("fixture does not distinguish ZIP and raw limits")
}
_, err := service.Unpack(context.Background(), bytes.NewReader(raw), int64(len(raw)), t.TempDir())
if !errors.Is(err, ErrLimitExceeded) {
t.Fatalf("Unpack got %v", err)
}
var output bytes.Buffer
err = service.Pack(context.Background(), snapshot, localFiles(files, nil), &output)
if !errors.Is(err, ErrLimitExceeded) || output.Len() != 0 {
t.Fatalf("Pack got %v", err)
}
})
}
}
@@ -0,0 +1,253 @@
package game_package_service
import (
"archive/zip"
"bytes"
"context"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"errors"
"io"
"os"
"path/filepath"
)
// Unpack performs no network or database operations. Only a fully validated bundle
// is returned. Failed attempts remove their private directory, never the parent.
func (s *GamePackageService) Unpack(ctx context.Context, input io.ReaderAt, size int64, tempDir string) (bundle *Bundle, err error) {
if err := ctx.Err(); err != nil {
return nil, err
}
if input == nil || size <= 0 {
return nil, invalid("пустой архив")
}
if size > s.limits.archive {
return nil, ErrLimitExceeded
}
if err := s.checkDirectory(input, size); err != nil {
return nil, err
}
zr, err := zip.NewReader(input, size)
if err != nil {
return nil, errors.Join(ErrInvalidPackage, err)
}
if len(zr.File) > s.limits.entries {
return nil, ErrLimitExceeded
}
entries := map[string]*zip.File{}
var total int64
for _, file := range zr.File {
if !validPath(file.Name) || !file.Mode().IsRegular() || entries[file.Name] != nil || file.Flags&1 != 0 ||
(file.Name != "manifest.json" && file.Name != "game.json" && !mediaPath(file.Name)) {
return nil, invalid("недопустимая или повторная запись ZIP")
}
limit := s.limits.file
if file.Name == "manifest.json" || file.Name == "game.json" {
limit = s.limits.json
}
if file.UncompressedSize64 > uint64(limit) || file.UncompressedSize64 > uint64(s.limits.archive-total) {
return nil, ErrLimitExceeded
}
total += int64(file.UncompressedSize64)
entries[file.Name] = file
}
manifestRaw, err := readJSONEntry(ctx, entries["manifest.json"], s.limits.json)
if err != nil {
return nil, err
}
var manifest Manifest
if err := decodeJSON(manifestRaw, &manifest); err != nil {
return nil, err
}
if manifest.Version != Version {
return nil, ErrUnsupportedVersion
}
id, idErr := hex.DecodeString(manifest.PackageID)
if idErr != nil || len(id) != 16 || manifest.CreatedAt.IsZero() || !validServer(manifest.SourceServer) || manifest.GameID <= 0 ||
len(manifest.Files) != len(entries)-1 {
return nil, invalid("некорректный манифест")
}
declared := map[string]FileInfo{}
for _, info := range manifest.Files {
file := entries[info.Path]
if info.Path == "manifest.json" || file == nil || info.Size < 0 || uint64(info.Size) != file.UncompressedSize64 ||
!validHash(info.SHA256) || info.MIME == "" {
return nil, invalid("некорректная запись манифеста")
}
if _, duplicate := declared[info.Path]; duplicate {
return nil, invalid("повторная запись манифеста")
}
declared[info.Path] = info
}
gameRaw, err := readJSONEntry(ctx, entries["game.json"], s.limits.json)
if err != nil {
return nil, err
}
gameInfo, ok := declared["game.json"]
if !ok || gameInfo.MIME != "application/json" || describeBytes("game.json", "application/json", gameRaw).SHA256 != gameInfo.SHA256 {
return nil, invalid("неверная контрольная сумма данных игры")
}
var data Data
if err := decodeJSON(gameRaw, &data); err != nil {
return nil, err
}
if data.Game.ID != manifest.GameID {
return nil, invalid("игра не соответствует манифесту")
}
if err := validateData(&data); err != nil {
return nil, err
}
used := map[string]bool{}
for _, ref := range mediaRefs(&data) {
if *ref == "" {
continue
}
if !mediaPath(*ref) || entries[*ref] == nil {
return nil, invalid("отсутствует файл по ссылке")
}
used[*ref] = true
}
if len(used)+1 != len(declared) {
return nil, invalid("пакет содержит посторонние файлы")
}
dir, err := os.MkdirTemp(tempDir, "game-package-unpack-")
if err != nil {
return nil, err
}
defer func() {
if err != nil {
_ = os.RemoveAll(dir)
}
}()
bundle = &Bundle{
Manifest: manifest,
Data: data,
Dir: dir,
Files: make(map[string]string, len(used)),
}
for name := range used {
filename, err := s.extractMedia(ctx, dir, entries[name], declared[name])
if err != nil {
return nil, err
}
bundle.Files[name] = filename
}
if err := ctx.Err(); err != nil {
return nil, err
}
return bundle, nil
}
func readJSONEntry(ctx context.Context, entry *zip.File, limit int64) ([]byte, error) {
if entry == nil {
return nil, invalid("отсутствует обязательный JSON")
}
r, err := entry.Open()
if err != nil {
return nil, errors.Join(ErrInvalidPackage, err)
}
defer func() { _ = r.Close() }()
var buffer bytes.Buffer
if _, err := copyLimited(ctx, &buffer, r, limit); err != nil {
return nil, errors.Join(ErrInvalidPackage, err)
}
return buffer.Bytes(), nil
}
func (s *GamePackageService) extractMedia(ctx context.Context, dir string, entry *zip.File, info FileInfo) (string, error) {
r, err := entry.Open()
if err != nil {
return "", errors.Join(ErrInvalidPackage, err)
}
defer func() { _ = r.Close() }()
// Generated private filenames never depend on archive paths or existing parent files.
file, err := os.CreateTemp(dir, "media-*")
if err != nil {
return "", err
}
defer func() { _ = file.Close() }()
hash := sha256.New()
n, err := copyLimited(ctx, io.MultiWriter(file, hash), r, min(s.limits.file, info.Size))
if err != nil {
return "", errors.Join(ErrInvalidPackage, err)
}
if n == 0 || n != info.Size || hex.EncodeToString(hash.Sum(nil)) != info.SHA256 {
return "", invalid("неверный размер или контрольная сумма медиафайла")
}
if _, err := file.Seek(0, io.SeekStart); err != nil {
return "", err
}
header := make([]byte, 512)
nread, err := file.Read(header)
if err != nil && !errors.Is(err, io.EOF) {
return "", err
}
mime, ext := mediaType(header[:nread])
if ext == "" || mime != info.MIME || filepath.Ext(info.Path) != ext || info.Path != "files/"+info.SHA256+ext {
return "", invalid("содержимое файла не соответствует манифесту")
}
if err := file.Close(); err != nil {
return "", err
}
filename := file.Name() + ext
if err := os.Rename(file.Name(), filename); err != nil {
return "", err
}
return filename, nil
}
// Bound the central directory before archive/zip allocates one object per entry.
// Version 1 cannot need ZIP64: all counts and sizes fit in ordinary ZIP fields.
func (s *GamePackageService) checkDirectory(input io.ReaderAt, size int64) error {
tail := make([]byte, min(size, int64(65535+22)))
if _, err := input.ReadAt(tail, size-int64(len(tail))); err != nil {
return errors.Join(ErrInvalidPackage, err)
}
for i := len(tail) - 22; i >= 0; i-- {
if !bytes.Equal(tail[i:i+4], []byte{'P', 'K', 5, 6}) ||
i+22+int(binary.LittleEndian.Uint16(tail[i+20:i+22])) != len(tail) {
continue
}
disk := binary.LittleEndian.Uint16(tail[i+4 : i+6])
centralDisk := binary.LittleEndian.Uint16(tail[i+6 : i+8])
diskCount := binary.LittleEndian.Uint16(tail[i+8 : i+10])
count := binary.LittleEndian.Uint16(tail[i+10 : i+12])
length := binary.LittleEndian.Uint32(tail[i+12 : i+16])
offset := binary.LittleEndian.Uint32(tail[i+16 : i+20])
if disk != 0 || centralDisk != 0 || diskCount != count || count == 65535 || offset == 0xffffffff || length == 0xffffffff {
return invalid("ZIP64 и многотомные архивы не поддерживаются")
}
if int(count) > s.limits.entries || int64(length) > s.limits.json {
return ErrLimitExceeded
}
if int64(offset)+int64(length) != size-int64(len(tail))+int64(i) {
return invalid("некорректный каталог ZIP")
}
// Every central header is at least 46 bytes; checkDirectory cannot trust count alone.
central := make([]byte, int(length))
if _, err := input.ReadAt(central, int64(offset)); err != nil {
return errors.Join(ErrInvalidPackage, err)
}
actual := 0
for len(central) > 0 {
if len(central) < 46 || !bytes.Equal(central[:4], []byte{'P', 'K', 1, 2}) {
return invalid("некорректная запись каталога ZIP")
}
n := 46 + int(binary.LittleEndian.Uint16(central[28:30])) + int(binary.LittleEndian.Uint16(central[30:32])) + int(binary.LittleEndian.Uint16(central[32:34]))
if n > len(central) {
return invalid("обрезанная запись каталога ZIP")
}
central = central[n:]
actual++
if actual > s.limits.entries {
return ErrLimitExceeded
}
}
if actual != int(count) {
return invalid("неверное число записей ZIP")
}
return nil
}
return invalid("отсутствует каталог ZIP")
}
@@ -0,0 +1,197 @@
package game_package_service
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"net/url"
"path"
"strings"
"unicode"
)
func invalid(reason string) error {
return fmt.Errorf("%w: %s", ErrInvalidPackage, reason)
}
func validateData(data *Data) error {
g := data.Game
if g.ID <= 0 || strings.TrimSpace(g.Name) == "" || g.StartAt.IsZero() ||
g.Status != "draft" || g.StartedAt != nil || g.EndedAt != nil ||
(g.Format != "online" && g.Format != "offline") {
return invalid("игра должна быть подготовлена и не начата")
}
s := data.Scenario
if s.ID <= 0 || s.ID != g.ScenarioID || strings.TrimSpace(s.Name) == "" || s.Story == nil || !validUserID(s.AuthorID) {
return invalid("некорректный сценарий")
}
places := map[string]bool{}
for _, place := range s.Story.Places {
if place == nil {
return invalid("пустое место сценария")
}
if place.Code == "" || places[place.Code] {
return invalid("пустой или повторный код места")
}
places[place.Code] = true
for _, application := range place.Applications {
if application == nil {
return invalid("пустая улика сценария")
}
}
for _, key := range place.Keys {
if key == nil {
return invalid("пустой ключ сценария")
}
}
}
for _, place := range s.Story.Places {
for _, door := range place.Doors {
if door == nil || !places[door.Code] {
return invalid("дверь ведёт в отсутствующее место")
}
for _, key := range door.Keys {
if key == nil {
return invalid("пустой ключ двери")
}
}
}
}
questions := map[string]bool{}
for _, question := range s.Story.Questions {
if question == nil || question.Code == "" || questions[question.Code] {
return invalid("пустой или повторный вопрос")
}
questions[question.Code] = true
}
teams := map[int]bool{}
deliveries := map[string]bool{}
for _, team := range data.Teams {
if team.ID <= 0 || teams[team.ID] || team.GameID != g.ID || strings.TrimSpace(team.Name) == "" ||
team.Password == "" || team.Status != "register" ||
team.ParticipantsCount < 0 || !validUserID(team.CreatorID) ||
(team.ParticipationStatus != "pending" && team.ParticipationStatus != "confirmed" && team.ParticipationStatus != "cancelled") {
return invalid("некорректная команда")
}
if team.RegistrationDeliveryID != nil {
if deliveries[*team.RegistrationDeliveryID] {
return invalid("повторная регистрация команды")
}
deliveries[*team.RegistrationDeliveryID] = true
}
teams[team.ID] = true
}
tickets := map[int]bool{}
hashes := map[string]bool{}
for _, ticket := range data.Tickets {
if ticket.ID <= 0 || tickets[ticket.ID] || ticket.GameID != g.ID || len(ticket.TokenHash) != sha256.Size ||
hashes[string(ticket.TokenHash)] || !validUserID(ticket.UsedBy) ||
(ticket.Status != "active" && ticket.Status != "used") ||
(ticket.Status == "active" && (ticket.UsedAt != nil || ticket.UsedBy != nil)) ||
(ticket.Status == "used" && ticket.UsedAt == nil) {
return invalid("некорректный билет")
}
tickets[ticket.ID] = true
hashes[string(ticket.TokenHash)] = true
}
playlists := map[int]bool{}
tracks := map[int]bool{}
for _, playlist := range data.Playlists {
if playlist.ID <= 0 || playlists[playlist.ID] || strings.TrimSpace(playlist.Name) == "" {
return invalid("некорректный плейлист")
}
playlists[playlist.ID] = true
for _, track := range playlist.Tracks {
if track.ID <= 0 || tracks[track.ID] || strings.TrimSpace(track.Name) == "" || track.Filename == "" || track.Position < 0 {
return invalid("некорректный трек")
}
tracks[track.ID] = true
}
}
for _, id := range []*int{g.PlaylistID, s.PlaylistID} {
if id != nil && !playlists[*id] {
return invalid("отсутствует назначенный плейлист")
}
}
for id := range playlists {
if (g.PlaylistID == nil || *g.PlaylistID != id) && (s.PlaylistID == nil || *s.PlaylistID != id) {
return invalid("посторонний плейлист")
}
}
if plan := data.ScreenPlan; plan != nil {
if plan.GameID != g.ID || plan.CurrentStep < 0 ||
(len(plan.Steps) == 0 && plan.CurrentStep != 0) ||
(len(plan.Steps) > 0 && plan.CurrentStep >= len(plan.Steps)) {
return invalid("некорректный план экранов")
}
for _, step := range plan.Steps {
if step.Mode < 1 || step.Mode > 8 ||
(step.Mode == 7 && (step.TimerDurationSeconds <= 0 || step.TimerDurationSeconds > 86400 || len(step.TimerLabel) > 200)) ||
(step.Mode == 8 && (strings.TrimSpace(step.Text) == "" || len(step.Text) > 10000)) {
return invalid("некорректный шаг плана экранов")
}
}
}
items := map[string]bool{}
for _, item := range data.Map {
key := fmt.Sprintf("%s/%d", item.Kind, item.ItemID)
if item.GameID != g.ID || items[key] ||
(item.Kind != "team" && item.Kind != "organizer_desk") ||
(item.Kind == "team" && !teams[item.ItemID]) || item.ItemID <= 0 ||
(item.Kind == "organizer_desk" && item.ItemID != 1) {
return invalid("некорректный объект карты")
}
items[key] = true
}
return nil
}
func validUserID(id *int) bool {
return id == nil || *id > 0
}
func validServer(server string) bool {
u, err := url.Parse(server)
return err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Hostname() != "" &&
u.User == nil && u.RawQuery == "" && u.Fragment == ""
}
func validPath(name string) bool {
return name != "" && name != "." && !strings.HasPrefix(name, "/") &&
!strings.ContainsAny(name, "\\:") && path.Clean(name) == name &&
!strings.Contains(name, "../") && name != ".." &&
strings.IndexFunc(name, unicode.IsControl) < 0
}
func mediaPath(name string) bool {
return validPath(name) && strings.HasPrefix(name, "files/") && strings.Count(name, "/") == 1
}
func validHash(hash string) bool {
b, err := hex.DecodeString(hash)
return err == nil && len(b) == sha256.Size && hash == strings.ToLower(hash)
}
// mediaRefs returns writable references only after validateData has checked pointers.
func mediaRefs(data *Data) []*string {
story := data.Scenario.Story
refs := []*string{&data.Scenario.Image, &story.Background}
if story.Introduction != nil {
refs = append(refs, &story.Introduction.Audio)
}
if story.Conclusion != nil {
refs = append(refs, &story.Conclusion.Video)
}
for _, place := range story.Places {
refs = append(refs, &place.Image)
for _, application := range place.Applications {
refs = append(refs, &application.Image)
}
}
for i := range data.Playlists {
for j := range data.Playlists[i].Tracks {
refs = append(refs, &data.Playlists[i].Tracks[j].Filename)
}
}
return refs
}