0f3671f42d
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 23s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Successful in 45s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m5s
Replace the dictionary hot-reload with an online update flow in the GM console (/_gm/dictionary): the operator uploads scrabble-dawg-vX.Y.Z.tar.gz, previews the per-variant words added/removed against the active dictionary, and confirms to install + activate it. Versions are immutable; in-progress games keep their pinned version while new games use the new one. - engine: DiffWords (enumerate both DAWGs, decode only the differences), OpenFinder, Registry.Finder, DictFiles; OpenWithVersions skips the .staging area. - dictadmin: hardened release-archive validation + extraction (path-traversal, symlink, oversize, entry-count rejection) and staging -> install (atomic rename). - game: active dictionary version persisted in the dictionary_state singleton (single source of truth, restored on boot), concurrency-safe accessor. - storage: BACKEND_DICT_DIR is a named volume seeded from the image (nonroot-owned), so uploaded versions persist across redeploys; the build's DICT_VERSION labels the seed and equals the resident tag (BACKEND_DICT_VERSION). - docs: ARCHITECTURE §5, FUNCTIONAL (+ru), backend README, TESTING, PRERELEASE. Tests: engine + dictadmin unit; integration upload->preview->install->activate-> restart->pin->immutability->CSRF.
249 lines
7.5 KiB
Go
249 lines
7.5 KiB
Go
package dictadmin
|
|
|
|
import (
|
|
"archive/tar"
|
|
"bytes"
|
|
"compress/gzip"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"scrabble/backend/internal/engine"
|
|
)
|
|
|
|
// dawgNames are the three committed dictionary filenames a release archive holds.
|
|
func dawgNames() []string {
|
|
out := make([]string, 0, 3)
|
|
for _, name := range engine.DictFiles() {
|
|
out = append(out, name)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// fullArchive builds a gzip+tar archive holding all three dictionary files with
|
|
// dummy content, the shape of a real release archive.
|
|
func fullArchive(t *testing.T) []byte {
|
|
t.Helper()
|
|
files := map[string][]byte{}
|
|
for _, n := range dawgNames() {
|
|
files[n] = []byte("dawg:" + n)
|
|
}
|
|
return tarGz(t, files, nil)
|
|
}
|
|
|
|
// tarGz builds a gzip+tar archive. regular maps a member name to its bytes; extra
|
|
// lets a test inject crafted headers (symlinks, oversized members, junk entries).
|
|
func tarGz(t *testing.T, regular map[string][]byte, extra []*tar.Header) []byte {
|
|
t.Helper()
|
|
var buf bytes.Buffer
|
|
gz := gzip.NewWriter(&buf)
|
|
tw := tar.NewWriter(gz)
|
|
for name, data := range regular {
|
|
if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o644, Size: int64(len(data)), Typeflag: tar.TypeReg}); err != nil {
|
|
t.Fatalf("write header %s: %v", name, err)
|
|
}
|
|
if _, err := tw.Write(data); err != nil {
|
|
t.Fatalf("write %s: %v", name, err)
|
|
}
|
|
}
|
|
for _, hdr := range extra {
|
|
if err := tw.WriteHeader(hdr); err != nil {
|
|
t.Fatalf("write extra header %s: %v", hdr.Name, err)
|
|
}
|
|
if hdr.Typeflag == tar.TypeReg && hdr.Size > 0 {
|
|
if _, err := tw.Write(bytes.Repeat([]byte("x"), int(hdr.Size))); err != nil {
|
|
t.Fatalf("write extra %s: %v", hdr.Name, err)
|
|
}
|
|
}
|
|
}
|
|
if err := tw.Close(); err != nil {
|
|
t.Fatalf("close tar: %v", err)
|
|
}
|
|
if err := gz.Close(); err != nil {
|
|
t.Fatalf("close gzip: %v", err)
|
|
}
|
|
return buf.Bytes()
|
|
}
|
|
|
|
func TestParseVersionFromName(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
want string
|
|
wantErr bool
|
|
}{
|
|
{"scrabble-dawg-v1.2.3.tar.gz", "v1.2.3", false},
|
|
{"scrabble-dawg-v10.0.1.tar.gz", "v10.0.1", false},
|
|
{"scrabble-dawg-v1.2.tar.gz", "", true},
|
|
{"scrabble-dawg-1.2.3.tar.gz", "", true},
|
|
{"scrabble-dawg-v1.2.3.zip", "", true},
|
|
{"dawg-v1.2.3.tar.gz", "", true},
|
|
{"scrabble-dawg-v1.2.3.tar.gz.exe", "", true},
|
|
{"", "", true},
|
|
}
|
|
for _, c := range cases {
|
|
got, err := ParseVersionFromName(c.name)
|
|
if c.wantErr {
|
|
if err == nil {
|
|
t.Errorf("ParseVersionFromName(%q) = %q, want error", c.name, got)
|
|
}
|
|
continue
|
|
}
|
|
if err != nil || got != c.want {
|
|
t.Errorf("ParseVersionFromName(%q) = %q, %v; want %q", c.name, got, err, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestValidVersion(t *testing.T) {
|
|
valid := []string{"v1.0.0", "v10.20.30", "v0.0.1"}
|
|
invalid := []string{"v1.0", "1.0.0", "v1.0.0.0", "v1.0.0/..", "v1.0.0 ", "", "vx.y.z", "../v1.0.0"}
|
|
for _, v := range valid {
|
|
if !ValidVersion(v) {
|
|
t.Errorf("ValidVersion(%q) = false, want true", v)
|
|
}
|
|
}
|
|
for _, v := range invalid {
|
|
if ValidVersion(v) {
|
|
t.Errorf("ValidVersion(%q) = true, want false", v)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestExtractAcceptsFullArchive(t *testing.T) {
|
|
dir := t.TempDir()
|
|
variants, err := Extract(bytes.NewReader(fullArchive(t)), dir)
|
|
if err != nil {
|
|
t.Fatalf("Extract: %v", err)
|
|
}
|
|
if len(variants) != len(engine.Variants()) {
|
|
t.Errorf("variants = %v, want all %d", variants, len(engine.Variants()))
|
|
}
|
|
for _, n := range dawgNames() {
|
|
if _, err := os.Stat(filepath.Join(dir, n)); err != nil {
|
|
t.Errorf("missing extracted %s: %v", n, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestExtractIgnoresUnexpectedAndTraversal(t *testing.T) {
|
|
dir := t.TempDir()
|
|
files := map[string][]byte{
|
|
"pkg/en_sowpods.dawg": []byte("ok"), // a subdir path is stripped to its base
|
|
"README.txt": []byte("junk"), // an unexpected name is ignored
|
|
}
|
|
// A traversal name with a recognised base must still land inside destDir.
|
|
traversal := &tar.Header{Name: "../ru_scrabble.dawg", Mode: 0o644, Size: 2, Typeflag: tar.TypeReg}
|
|
// A symlink, even named like a DAWG, is ignored.
|
|
symlink := &tar.Header{Name: "erudit_ru.dawg", Linkname: "/etc/passwd", Typeflag: tar.TypeSymlink}
|
|
|
|
if _, err := Extract(bytes.NewReader(tarGz(t, files, []*tar.Header{traversal, symlink})), dir); err != nil {
|
|
t.Fatalf("Extract: %v", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dir, "README.txt")); err == nil {
|
|
t.Error("README.txt was extracted; unexpected files must be ignored")
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dir, "en_sowpods.dawg")); err != nil {
|
|
t.Errorf("en_sowpods.dawg not extracted from a subdir entry: %v", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dir, "ru_scrabble.dawg")); err != nil {
|
|
t.Errorf("ru_scrabble.dawg not extracted from a traversal entry: %v", err)
|
|
}
|
|
// Nothing escaped the destination directory.
|
|
if _, err := os.Stat(filepath.Join(filepath.Dir(dir), "ru_scrabble.dawg")); err == nil {
|
|
t.Error("path traversal wrote outside the destination directory")
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dir, "erudit_ru.dawg")); err == nil {
|
|
t.Error("a symlink member was materialised")
|
|
}
|
|
}
|
|
|
|
func TestExtractRejectsOversizeMember(t *testing.T) {
|
|
old := maxMemberBytes
|
|
maxMemberBytes = 8
|
|
defer func() { maxMemberBytes = old }()
|
|
|
|
files := map[string][]byte{"en_sowpods.dawg": bytes.Repeat([]byte("x"), 64)}
|
|
if _, err := Extract(bytes.NewReader(tarGz(t, files, nil)), t.TempDir()); err == nil {
|
|
t.Error("Extract accepted an oversize member, want error")
|
|
}
|
|
}
|
|
|
|
func TestExtractRejectsTooManyEntries(t *testing.T) {
|
|
var extra []*tar.Header
|
|
for i := 0; i < maxEntries+1; i++ {
|
|
extra = append(extra, &tar.Header{Name: fmt.Sprintf("junk-%d.bin", i), Mode: 0o644, Size: 1, Typeflag: tar.TypeReg})
|
|
}
|
|
if _, err := Extract(bytes.NewReader(tarGz(t, nil, extra)), t.TempDir()); err == nil {
|
|
t.Error("Extract accepted an archive with too many entries, want error")
|
|
}
|
|
}
|
|
|
|
func TestManagerStageInstall(t *testing.T) {
|
|
dir := t.TempDir()
|
|
m := New(dir)
|
|
|
|
token, variants, err := m.Stage(bytes.NewReader(fullArchive(t)))
|
|
if err != nil {
|
|
t.Fatalf("Stage: %v", err)
|
|
}
|
|
if len(variants) != len(engine.Variants()) {
|
|
t.Fatalf("staged variants = %v, want all", variants)
|
|
}
|
|
staged, err := m.StagedDir(token)
|
|
if err != nil {
|
|
t.Fatalf("StagedDir: %v", err)
|
|
}
|
|
if !strings.Contains(staged, filepath.Join(".staging", token)) {
|
|
t.Errorf("staged dir %q not under .staging/%s", staged, token)
|
|
}
|
|
|
|
if m.VersionExists("v1.0.0") {
|
|
t.Fatal("v1.0.0 should not exist before install")
|
|
}
|
|
dest, err := m.Install(token, "v1.0.0")
|
|
if err != nil {
|
|
t.Fatalf("Install: %v", err)
|
|
}
|
|
if dest != filepath.Join(dir, "v1.0.0") {
|
|
t.Errorf("install dest = %q, want %q", dest, filepath.Join(dir, "v1.0.0"))
|
|
}
|
|
if !m.VersionExists("v1.0.0") {
|
|
t.Error("VersionExists(v1.0.0) = false after install")
|
|
}
|
|
for _, n := range dawgNames() {
|
|
if _, err := os.Stat(filepath.Join(dest, n)); err != nil {
|
|
t.Errorf("installed %s missing: %v", n, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestManagerInstallRejectsExistingVersion(t *testing.T) {
|
|
dir := t.TempDir()
|
|
m := New(dir)
|
|
token, _, err := m.Stage(bytes.NewReader(fullArchive(t)))
|
|
if err != nil {
|
|
t.Fatalf("Stage: %v", err)
|
|
}
|
|
if _, err := m.Install(token, "v2.0.0"); err != nil {
|
|
t.Fatalf("first Install: %v", err)
|
|
}
|
|
token2, _, err := m.Stage(bytes.NewReader(fullArchive(t)))
|
|
if err != nil {
|
|
t.Fatalf("second Stage: %v", err)
|
|
}
|
|
if _, err := m.Install(token2, "v2.0.0"); err == nil {
|
|
t.Error("Install overwrote an existing version, want error (versions are immutable)")
|
|
}
|
|
}
|
|
|
|
func TestStagedDirRejectsBadToken(t *testing.T) {
|
|
m := New(t.TempDir())
|
|
for _, bad := range []string{"", "../etc", "ABC", "zz", strings.Repeat("g", 32)} {
|
|
if _, err := m.StagedDir(bad); err == nil {
|
|
t.Errorf("StagedDir(%q) = nil error, want rejection", bad)
|
|
}
|
|
}
|
|
}
|