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) } } }