feat(admin): online dictionary update — upload archive, preview word diff, install & activate
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
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.
This commit is contained in:
@@ -0,0 +1,262 @@
|
||||
// Package dictadmin stages and installs scrabble-dictionary release archives for
|
||||
// the admin console's online dictionary-update flow. It validates the release
|
||||
// archive (scrabble-dawg-vX.Y.Z.tar.gz), extracts it safely into a per-version
|
||||
// directory under BACKEND_DICT_DIR, and keeps a staging area for the two-step
|
||||
// upload-then-confirm interaction. Extraction is hardened against the usual
|
||||
// archive risks (path traversal, symlinks, decompression bombs).
|
||||
package dictadmin
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
crand "crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"scrabble/backend/internal/engine"
|
||||
)
|
||||
|
||||
// MaxArchiveBytes bounds an uploaded release archive; the handler wraps the request
|
||||
// body in an http.MaxBytesReader with this limit. The three compressed DAWGs are a
|
||||
// few MB together, so 64 MiB is generous.
|
||||
const MaxArchiveBytes int64 = 64 << 20
|
||||
|
||||
// stagingRoot is the dot-prefixed directory under the dictionary dir holding
|
||||
// not-yet-installed uploads. The engine's boot scan skips dot-prefixed dirs, so a
|
||||
// staged upload never becomes a resident version.
|
||||
const stagingRoot = ".staging"
|
||||
|
||||
// stagingTTL is how long an abandoned staged upload lingers before Stage sweeps it.
|
||||
const stagingTTL = time.Hour
|
||||
|
||||
// maxMemberBytes and maxEntries bound a single archive member and the number of
|
||||
// entries, defeating decompression and entry-count bombs. They are variables so
|
||||
// tests can lower them. maxMemberBytes is per file; a real DAWG is a few MB.
|
||||
var (
|
||||
maxMemberBytes int64 = 32 << 20
|
||||
maxEntries = 64
|
||||
)
|
||||
|
||||
var (
|
||||
nameRE = regexp.MustCompile(`^scrabble-dawg-(v[0-9]+\.[0-9]+\.[0-9]+)\.tar\.gz$`)
|
||||
versionRE = regexp.MustCompile(`^v[0-9]+\.[0-9]+\.[0-9]+$`)
|
||||
tokenRE = regexp.MustCompile(`^[0-9a-f]{32}$`)
|
||||
)
|
||||
|
||||
// ParseVersionFromName extracts the version from a release archive filename of the
|
||||
// form scrabble-dawg-vX.Y.Z.tar.gz, or returns an error for any other shape.
|
||||
func ParseVersionFromName(filename string) (string, error) {
|
||||
m := nameRE.FindStringSubmatch(filename)
|
||||
if m == nil {
|
||||
return "", fmt.Errorf("dictadmin: %q is not a scrabble-dawg-vX.Y.Z.tar.gz archive", filename)
|
||||
}
|
||||
return m[1], nil
|
||||
}
|
||||
|
||||
// ValidVersion reports whether v is a vMAJOR.MINOR.PATCH version label. It guards
|
||||
// the operator's manual override before the version is used as a directory name.
|
||||
func ValidVersion(v string) bool {
|
||||
return versionRE.MatchString(v)
|
||||
}
|
||||
|
||||
// Extract reads a gzip+tar release archive from r and writes the recognised
|
||||
// dictionary files into destDir, returning the variants found in catalogue order.
|
||||
// Only regular files whose base name is a known DAWG filename are written (their
|
||||
// directory part is discarded, so a traversal entry cannot escape destDir);
|
||||
// symlinks and other types are ignored, and oversize members or an excessive entry
|
||||
// count are rejected.
|
||||
func Extract(r io.Reader, destDir string) ([]engine.Variant, error) {
|
||||
gz, err := gzip.NewReader(r)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dictadmin: open gzip: %w", err)
|
||||
}
|
||||
defer func() { _ = gz.Close() }()
|
||||
tr := tar.NewReader(gz)
|
||||
|
||||
byName := filenameToVariant()
|
||||
seen := make(map[engine.Variant]bool)
|
||||
entries := 0
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dictadmin: read archive: %w", err)
|
||||
}
|
||||
entries++
|
||||
if entries > maxEntries {
|
||||
return nil, fmt.Errorf("dictadmin: archive has more than %d entries", maxEntries)
|
||||
}
|
||||
if hdr.Typeflag != tar.TypeReg {
|
||||
continue
|
||||
}
|
||||
base := filepath.Base(filepath.Clean(hdr.Name))
|
||||
v, ok := byName[base]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if seen[v] {
|
||||
return nil, fmt.Errorf("dictadmin: duplicate %s in archive", base)
|
||||
}
|
||||
if hdr.Size > maxMemberBytes {
|
||||
return nil, fmt.Errorf("dictadmin: %s exceeds the %d-byte member limit", base, maxMemberBytes)
|
||||
}
|
||||
if err := writeMember(filepath.Join(destDir, base), tr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
seen[v] = true
|
||||
}
|
||||
|
||||
var found []engine.Variant
|
||||
for _, v := range engine.Variants() {
|
||||
if seen[v] {
|
||||
found = append(found, v)
|
||||
}
|
||||
}
|
||||
return found, nil
|
||||
}
|
||||
|
||||
// writeMember copies one archive member into path, bounding it at maxMemberBytes so
|
||||
// a member whose declared size lies cannot exhaust the disk.
|
||||
func writeMember(path string, tr *tar.Reader) error {
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dictadmin: create %s: %w", filepath.Base(path), err)
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
n, err := io.Copy(f, io.LimitReader(tr, maxMemberBytes+1))
|
||||
if err != nil {
|
||||
return fmt.Errorf("dictadmin: write %s: %w", filepath.Base(path), err)
|
||||
}
|
||||
if n > maxMemberBytes {
|
||||
return fmt.Errorf("dictadmin: %s exceeds the %d-byte member limit", filepath.Base(path), maxMemberBytes)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// filenameToVariant inverts the engine's variant-to-filename map.
|
||||
func filenameToVariant() map[string]engine.Variant {
|
||||
files := engine.DictFiles()
|
||||
out := make(map[string]engine.Variant, len(files))
|
||||
for v, name := range files {
|
||||
out[name] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Manager stages and installs release archives under a dictionary directory
|
||||
// (BACKEND_DICT_DIR).
|
||||
type Manager struct {
|
||||
dir string
|
||||
}
|
||||
|
||||
// New constructs a Manager rooted at the dictionary directory dir.
|
||||
func New(dir string) *Manager {
|
||||
return &Manager{dir: dir}
|
||||
}
|
||||
|
||||
// Stage accepts an uploaded archive: it sweeps abandoned previews, allocates a
|
||||
// staging directory under <dir>/.staging/<token>, extracts the archive there and
|
||||
// returns the token and the variants found. The staging directory is removed on an
|
||||
// extraction error.
|
||||
func (m *Manager) Stage(r io.Reader) (string, []engine.Variant, error) {
|
||||
m.cleanStaging()
|
||||
token, err := newToken()
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
dest := filepath.Join(m.dir, stagingRoot, token)
|
||||
if err := os.MkdirAll(dest, 0o755); err != nil {
|
||||
return "", nil, fmt.Errorf("dictadmin: create staging dir: %w", err)
|
||||
}
|
||||
variants, err := Extract(r, dest)
|
||||
if err != nil {
|
||||
_ = os.RemoveAll(dest)
|
||||
return "", nil, err
|
||||
}
|
||||
return token, variants, nil
|
||||
}
|
||||
|
||||
// StagedDir returns the directory of the staged upload named by token, after
|
||||
// validating the token's shape and confirming the directory exists.
|
||||
func (m *Manager) StagedDir(token string) (string, error) {
|
||||
if !tokenRE.MatchString(token) {
|
||||
return "", fmt.Errorf("dictadmin: invalid staging token")
|
||||
}
|
||||
dir := filepath.Join(m.dir, stagingRoot, token)
|
||||
info, err := os.Stat(dir)
|
||||
if err != nil || !info.IsDir() {
|
||||
return "", fmt.Errorf("dictadmin: staged upload not found")
|
||||
}
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
// VersionExists reports whether the version directory <dir>/<version> is present.
|
||||
func (m *Manager) VersionExists(version string) bool {
|
||||
info, err := os.Stat(filepath.Join(m.dir, version))
|
||||
return err == nil && info.IsDir()
|
||||
}
|
||||
|
||||
// Install promotes a staged upload into its version directory <dir>/<version> by an
|
||||
// atomic rename (same filesystem) and returns the destination path. It rejects an
|
||||
// invalid version and refuses to overwrite an existing version: versions are
|
||||
// immutable, protecting games pinned to that tag.
|
||||
func (m *Manager) Install(token, version string) (string, error) {
|
||||
if !ValidVersion(version) {
|
||||
return "", fmt.Errorf("dictadmin: invalid version %q", version)
|
||||
}
|
||||
staged, err := m.StagedDir(token)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if m.VersionExists(version) {
|
||||
return "", fmt.Errorf("dictadmin: version %s already exists", version)
|
||||
}
|
||||
dest := filepath.Join(m.dir, version)
|
||||
if err := os.Rename(staged, dest); err != nil {
|
||||
return "", fmt.Errorf("dictadmin: install %s: %w", version, err)
|
||||
}
|
||||
return dest, nil
|
||||
}
|
||||
|
||||
// Discard removes the staging directory named by token, best effort. It is called
|
||||
// when a preview is rejected (an incomplete or unusable archive).
|
||||
func (m *Manager) Discard(token string) {
|
||||
if dir, err := m.StagedDir(token); err == nil {
|
||||
_ = os.RemoveAll(dir)
|
||||
}
|
||||
}
|
||||
|
||||
// cleanStaging removes staging directories older than stagingTTL, best effort.
|
||||
func (m *Manager) cleanStaging() {
|
||||
root := filepath.Join(m.dir, stagingRoot)
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
cutoff := time.Now().Add(-stagingTTL)
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
if info, err := e.Info(); err == nil && info.ModTime().Before(cutoff) {
|
||||
_ = os.RemoveAll(filepath.Join(root, e.Name()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// newToken returns a 32-hex-character random staging token.
|
||||
func newToken() (string, error) {
|
||||
var b [16]byte
|
||||
if _, err := crand.Read(b[:]); err != nil {
|
||||
return "", fmt.Errorf("dictadmin: generate token: %w", err)
|
||||
}
|
||||
return hex.EncodeToString(b[:]), nil
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user