// 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