Files
beacon-server/internal/backup/verify.go
T

213 lines
7.0 KiB
Go

// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
package backup
import (
"archive/tar"
"bufio"
"bytes"
"compress/gzip"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"strconv"
"strings"
)
const maxManifestBytes = 64 << 10
var ErrInvalidArchive = errors.New("invalid backup archive")
// Verify streams a format-1 bundle without extracting files or executing SQL.
// maxBytes bounds the SQL payload (up to 1 TiB); the saved YAML and manifest have
// separate fixed limits. The caller owns input and its deadline. Cancellation is
// checked between reads; the supplied Reader must not block indefinitely.
// The returned manifest is untrusted metadata, not proof of origin or restorability.
func Verify(ctx context.Context, input io.Reader, maxBytes int64) (*Manifest, error) {
if input == nil || maxBytes <= 0 || maxBytes > 1<<40 {
return nil, errors.New("input and positive max-bytes (at most 1 TiB) are required")
}
// Match the exporter's compressed-output allowance. ByteReader prevents gzip
// from swallowing a following stream before we can reject it.
compressed := &verifyReader{ctx: ctx, r: input, limit: maxBytes + maxBytes/100 + 2*maxConfigBytes}
buffered := bufio.NewReader(compressed)
gz, err := gzip.NewReader(buffered)
if err != nil {
return nil, verificationError(ctx, err, "gzip header")
}
defer gz.Close()
gz.Multistream(false)
decoded := &verifyReader{ctx: ctx, r: gz}
tr := tar.NewReader(decoded)
files := make(map[string]File, 3)
var metadata []byte
var padding int64
for {
before := decoded.n
// Native headers are USTAR, or one size-only PAX record for SQL >8 GiB.
// Bound Next itself: it otherwise hides and allocates extension records.
decoded.limit = before + padding + 3*512
h, err := tr.Next()
if errors.Is(err, ErrTooLarge) {
// The 3-block header budget is a structure check, not the size limit.
return nil, verificationError(ctx, nil, "tar header")
}
if err == io.EOF {
// tar.Reader also accepts EOF without both closing zero blocks.
if decoded.n-before != padding+2*512 {
return nil, verificationError(ctx, nil, "missing tar terminator")
}
break
}
if err != nil {
return nil, verificationError(ctx, err, "tar header")
}
headerBytes := decoded.n - before - padding
ordinary := h.Format == tar.FormatUSTAR && headerBytes == 512 && len(h.PAXRecords) == 0
large := h.Format == tar.FormatPAX && headerBytes == 3*512 && len(h.PAXRecords) == 1 && h.PAXRecords["size"] == strconv.FormatInt(h.Size, 10)
if (!ordinary && !large) || (h.Typeflag != tar.TypeReg && h.Typeflag != tar.TypeRegA) || h.Linkname != "" || h.Mode != 0600 {
return nil, verificationError(ctx, nil, "unsupported member type or metadata")
}
if _, duplicate := files[h.Name]; duplicate || len(files) == 3 {
return nil, verificationError(ctx, nil, "duplicate or extra member")
}
var limit int64
switch h.Name {
case "manifest.json":
limit = maxManifestBytes
case "database.sql":
limit = maxBytes
if h.Size == 0 {
return nil, verificationError(ctx, nil, "empty database dump")
}
case "config.yaml":
limit = maxConfigBytes
default:
return nil, verificationError(ctx, nil, "unexpected member name")
}
if h.Size < 0 || h.Size > limit {
return nil, ErrTooLarge
}
decoded.limit = decoded.n + h.Size
digest := sha256.New()
if h.Name == "manifest.json" {
metadata, err = io.ReadAll(tr)
} else {
_, err = io.Copy(digest, tr)
}
if err != nil {
return nil, verificationError(ctx, err, "member data")
}
files[h.Name] = File{Name: h.Name, Size: h.Size, SHA256: hex.EncodeToString(digest.Sum(nil))}
padding = (512 - h.Size%512) % 512
}
// Read through the gzip checksum/trailer, but allow no data after tar EOF.
decoded.limit = decoded.n
var tail [1]byte
if _, err := decoded.Read(tail[:]); err != io.EOF {
if errors.Is(err, ErrTooLarge) {
err = nil // bytes after the tar terminator are corruption, not oversize
}
return nil, verificationError(ctx, err, "gzip completion or trailing tar data")
}
if _, err := buffered.ReadByte(); err != io.EOF {
return nil, verificationError(ctx, err, "trailing compressed data")
}
if len(files) != 3 || uniqueManifestKeys(json.NewDecoder(bytes.NewReader(metadata)), 0) != nil {
return nil, verificationError(ctx, nil, "missing members or invalid manifest")
}
var manifest Manifest
decoder := json.NewDecoder(bytes.NewReader(metadata))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&manifest); err != nil || decoder.Decode(new(any)) != io.EOF || manifest.FormatVersion != 1 || manifest.DatabaseFormat != "postgresql-plain-sql" || manifest.CreatedAt.IsZero() || len(manifest.Files) != 2 {
return nil, verificationError(ctx, nil, "unsupported or incomplete manifest")
}
for _, declared := range manifest.Files {
actual, exists := files[declared.Name]
digest, err := hex.DecodeString(declared.SHA256)
if !exists || declared.Name == "manifest.json" || declared.Size != actual.Size || err != nil || len(digest) != sha256.Size || !strings.EqualFold(declared.SHA256, actual.SHA256) {
return nil, verificationError(ctx, nil, "manifest size or checksum mismatch")
}
delete(files, declared.Name) // duplicate manifest file records fail as well.
}
if err := ctx.Err(); err != nil {
return nil, err
}
return &manifest, nil
}
// Avoid returning filenames, manifest values, reader errors or payload text.
func verificationError(ctx context.Context, err error, stage string) error {
if ctx.Err() != nil {
return ctx.Err()
}
if errors.Is(err, ErrTooLarge) {
return ErrTooLarge
}
return fmt.Errorf("%w: %s", ErrInvalidArchive, stage)
}
type verifyReader struct {
ctx context.Context
r io.Reader
n, limit int64
}
func (r *verifyReader) Read(p []byte) (int, error) {
if err := r.ctx.Err(); err != nil {
return 0, err
}
if r.n > r.limit {
return 0, ErrTooLarge
}
if int64(len(p)) > r.limit-r.n+1 {
p = p[:r.limit-r.n+1]
}
n, err := r.r.Read(p)
r.n += int64(n)
if r.n > r.limit {
return 0, ErrTooLarge
}
return n, err
}
// encoding/json accepts duplicate keys and case aliases. Reject both before
// decoding the fixed schema, so another reader cannot interpret different values.
func uniqueManifestKeys(d *json.Decoder, depth int) error {
if depth > 8 {
return ErrInvalidArchive
}
token, err := d.Token()
if err != nil {
return err
}
if delim, ok := token.(json.Delim); ok {
keys := map[string]bool{}
for d.More() {
if delim == '{' {
key, err := d.Token()
if err != nil {
return err
}
name, ok := key.(string)
// Schema keys are ASCII. Unicode simple-fold aliases (such as
// long-s for s) also match encoding/json fields, even in lowercase.
if !ok || keys[name] || strings.IndexFunc(name, func(r rune) bool { return r > 127 || r >= 'A' && r <= 'Z' }) >= 0 {
return ErrInvalidArchive
}
keys[name] = true
}
if err := uniqueManifestKeys(d, depth+1); err != nil {
return err
}
}
_, err = d.Token()
}
return err
}