mirror of
https://github.com/MeshCore-Beacon/beacon-server.git
synced 2026-10-16 16:00:03 +00:00
213 lines
7.0 KiB
Go
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
|
|
}
|