// 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 /.staging/, 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 / 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 / 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 }