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

267 lines
10 KiB
Go

// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
package backup
import (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"io"
"os"
"strings"
"testing"
"time"
)
type verifyEntry struct {
name string
data []byte
kind byte
pax map[string]string
}
func verifyFixture(t testing.TB) []verifyEntry {
t.Helper()
sql, config := []byte(testSQL), []byte("secret: PRIVATE_CANARY\n")
m := Manifest{FormatVersion: 1, CreatedAt: time.Now().UTC(), ToolVersion: "fixture", DatabaseFormat: "postgresql-plain-sql", Excluded: []string{"deployment_environment"}}
for _, entry := range []verifyEntry{{name: "database.sql", data: sql}, {name: "config.yaml", data: config}} {
hash := sha256.Sum256(entry.data)
m.Files = append(m.Files, File{Name: entry.name, Size: int64(len(entry.data)), SHA256: hex.EncodeToString(hash[:])})
}
metadata, err := json.Marshal(m)
if err != nil {
t.Fatal(err)
}
return []verifyEntry{{name: "manifest.json", data: metadata}, {name: "database.sql", data: sql}, {name: "config.yaml", data: config}}
}
func verifyTar(t testing.TB, entries []verifyEntry) []byte {
t.Helper()
var data bytes.Buffer
tw := tar.NewWriter(&data)
for _, e := range entries {
h := &tar.Header{Name: e.name, Size: int64(len(e.data)), Mode: 0600, Typeflag: e.kind}
if e.kind == tar.TypeSymlink || e.kind == tar.TypeLink {
h.Linkname = "../PRIVATE_CANARY"
}
if e.pax != nil {
h.Format, h.PAXRecords = tar.FormatPAX, e.pax
}
if err := tw.WriteHeader(h); err != nil {
t.Fatal(err)
}
if _, err := tw.Write(e.data); err != nil {
t.Fatal(err)
}
}
if err := tw.Close(); err != nil {
t.Fatal(err)
}
return data.Bytes()
}
func verifyGzip(t testing.TB, data []byte) []byte {
t.Helper()
var encoded bytes.Buffer
gz := gzip.NewWriter(&encoded)
if _, err := gz.Write(data); err != nil {
t.Fatal(err)
}
if err := gz.Close(); err != nil {
t.Fatal(err)
}
return encoded.Bytes()
}
func TestVerifyExportAndReorderedMembers(t *testing.T) {
opts := setup(t)
if err := export(context.Background(), opts, helper(t, "ok")); err != nil {
t.Fatal(err)
}
original, err := os.ReadFile(opts.OutputPath)
if err != nil {
t.Fatal(err)
}
m, err := Verify(context.Background(), bytes.NewReader(original), opts.MaxBytes)
if err != nil || m.ToolVersion != opts.Version || len(m.Files) != 2 {
t.Fatalf("native export: %v %v", m, err)
}
entries := verifyFixture(t)
entries[0], entries[2] = entries[2], entries[0]
if _, err := Verify(context.Background(), bytes.NewReader(verifyGzip(t, verifyTar(t, entries))), int64(len(testSQL))); err != nil {
t.Fatal("valid reordered members:", err)
}
if current, err := os.ReadFile(opts.OutputPath); err != nil || !bytes.Equal(original, current) {
t.Fatal("verification changed input")
}
}
func TestVerifyRejectsInvalidMembersAndManifest(t *testing.T) {
for _, tc := range []struct {
name string
change func([]verifyEntry) []verifyEntry
}{
{"missing", func(e []verifyEntry) []verifyEntry { return e[:2] }},
{"duplicate", func(e []verifyEntry) []verifyEntry { return append(e, e[1]) }},
{"traversal", func(e []verifyEntry) []verifyEntry { e[2].name = "../PRIVATE_CANARY"; return e }},
{"absolute", func(e []verifyEntry) []verifyEntry { e[2].name = "/config.yaml"; return e }},
{"windows path", func(e []verifyEntry) []verifyEntry { e[2].name = `C:\config.yaml`; return e }},
{"directory prefix", func(e []verifyEntry) []verifyEntry { e[2].name = "./config.yaml"; return e }},
{"symlink", func(e []verifyEntry) []verifyEntry { e[2].kind, e[2].data = tar.TypeSymlink, nil; return e }},
{"hardlink", func(e []verifyEntry) []verifyEntry { e[2].kind, e[2].data = tar.TypeLink, nil; return e }},
{"device", func(e []verifyEntry) []verifyEntry { e[2].kind, e[2].data = tar.TypeChar, nil; return e }},
{"directory", func(e []verifyEntry) []verifyEntry { e[2].kind, e[2].data = tar.TypeDir, nil; return e }},
{"payload tamper", func(e []verifyEntry) []verifyEntry { e[1].data[0] ^= 1; return e }},
{"large sql", func(e []verifyEntry) []verifyEntry { e[1].data = bytes.Repeat([]byte("x"), len(testSQL)+1); return e }},
{"large config", func(e []verifyEntry) []verifyEntry { e[2].data = bytes.Repeat([]byte("x"), maxConfigBytes+1); return e }},
{"large manifest", func(e []verifyEntry) []verifyEntry {
e[0].data = bytes.Repeat([]byte("x"), maxManifestBytes+1)
return e
}},
{"extended attributes", func(e []verifyEntry) []verifyEntry {
e[1].pax = map[string]string{"SCHILY.xattr.secret": "PRIVATE_CANARY"}
return e
}},
{"hidden oversized metadata", func(e []verifyEntry) []verifyEntry {
e[1].pax = map[string]string{"comment": strings.Repeat("x", 8000)}
return e
}},
{"unknown version", func(e []verifyEntry) []verifyEntry {
e[0].data = bytes.Replace(e[0].data, []byte(`"format_version":1`), []byte(`"format_version":2`), 1)
return e
}},
{"duplicate key", func(e []verifyEntry) []verifyEntry {
e[0].data = append([]byte(`{"format_version":2,`), e[0].data[1:]...)
return e
}},
{"case alias", func(e []verifyEntry) []verifyEntry {
e[0].data = bytes.Replace(e[0].data, []byte("format_version"), []byte("FORMAT_VERSION"), 1)
return e
}},
{"unicode case alias", func(e []verifyEntry) []verifyEntry {
e[0].data = bytes.Replace(e[0].data, []byte("database_format"), []byte(`databa\u017fe_format`), 1)
return e
}},
{"duplicate file record", func(e []verifyEntry) []verifyEntry {
var m Manifest
_ = json.Unmarshal(e[0].data, &m)
m.Files[1] = m.Files[0]
e[0].data, _ = json.Marshal(m)
return e
}},
{"claimed size", func(e []verifyEntry) []verifyEntry {
var m Manifest
_ = json.Unmarshal(e[0].data, &m)
m.Files[0].Size++
e[0].data, _ = json.Marshal(m)
return e
}},
{"unknown field", func(e []verifyEntry) []verifyEntry {
e[0].data = append([]byte(`{"private":"PRIVATE_CANARY",`), e[0].data[1:]...)
return e
}},
{"second json value", func(e []verifyEntry) []verifyEntry { e[0].data = append(e[0].data, []byte(`{}`)...); return e }},
} {
t.Run(tc.name, func(t *testing.T) {
data := verifyGzip(t, verifyTar(t, tc.change(verifyFixture(t))))
_, err := Verify(context.Background(), bytes.NewReader(data), int64(len(testSQL)))
if err == nil || strings.Contains(err.Error(), "PRIVATE_CANARY") {
t.Fatalf("expected a sanitized rejection, got %v", err)
}
// PAX header size errors should be corruption, not size limit.
if tc.name == "hidden oversized metadata" && errors.Is(err, ErrTooLarge) {
t.Fatalf("oversized header reported as size limit: %v", err)
}
})
}
}
func TestVerifyFraming(t *testing.T) {
raw := verifyTar(t, verifyFixture(t))
valid := verifyGzip(t, raw)
badCRC := bytes.Clone(valid)
badCRC[len(badCRC)-8] ^= 1
cases := map[string][]byte{
"not gzip": []byte("PRIVATE_CANARY"), "truncated gzip": valid[:len(valid)-1], "bad checksum": badCRC,
"no tar terminator": verifyGzip(t, raw[:len(raw)-1024]), "one tar terminator": verifyGzip(t, raw[:len(raw)-512]),
"truncated payload": verifyGzip(t, raw[:1500]), "trailing tar bytes": verifyGzip(t, append(bytes.Clone(raw), 0)),
"trailing compressed bytes": append(bytes.Clone(valid), 0), "second gzip stream": append(bytes.Clone(valid), verifyGzip(t, nil)...),
}
for name, data := range cases {
t.Run(name, func(t *testing.T) {
_, err := Verify(context.Background(), bytes.NewReader(data), DefaultMaxBytes)
if err == nil || strings.Contains(err.Error(), "PRIVATE_CANARY") {
t.Fatalf("expected sanitized framing error, got %v", err)
}
if errors.Is(err, ErrTooLarge) {
t.Fatalf("framing corruption reported as size limit: %v", err)
}
})
}
}
func TestVerifySizeOnlyPAX(t *testing.T) {
// A native dump over 8 GiB requires a size-only PAX header. Generate the
// real header without allocating its body; verification must reach the
// truncated member, rather than reject the exporter's metadata format.
var raw bytes.Buffer
tw := tar.NewWriter(&raw)
if err := tw.WriteHeader(&tar.Header{Name: "database.sql", Mode: 0600, Size: 1 << 34}); err != nil {
t.Fatal(err)
}
h, err := tar.NewReader(bytes.NewReader(raw.Bytes())).Next()
if err != nil || h.Format != tar.FormatPAX || len(h.PAXRecords) != 1 || h.PAXRecords["size"] != "17179869184" {
t.Fatalf("fixture must contain native size-only PAX metadata: %v %v", h, err)
}
data := verifyGzip(t, raw.Bytes())
if _, err := Verify(context.Background(), bytes.NewReader(data), 1<<35); !errors.Is(err, ErrInvalidArchive) || !strings.Contains(err.Error(), "member data") {
t.Fatalf("native PAX header was rejected before reading the payload: %v", err)
}
if _, err := Verify(context.Background(), bytes.NewReader(data), DefaultMaxBytes); !errors.Is(err, ErrTooLarge) {
t.Fatalf("PAX size did not respect the SQL limit: %v", err)
}
}
func TestVerifyBoundsAndCancellation(t *testing.T) {
for _, limit := range []int64{0, -1, 1<<40 + 1} {
if _, err := Verify(context.Background(), bytes.NewReader(nil), limit); err == nil {
t.Fatal("invalid limit accepted")
}
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := Verify(ctx, bytes.NewReader(nil), 1); !errors.Is(err, context.Canceled) {
t.Fatalf("cancellation lost: %v", err)
}
for _, size := range []int{2, 3} {
r := &verifyReader{ctx: context.Background(), r: bytes.NewReader(make([]byte, size)), limit: 2}
_, err := io.Copy(io.Discard, r)
if (size == 3 && !errors.Is(err, ErrTooLarge)) || (size == 2 && err != nil) {
t.Fatalf("read limit boundary: %d %v", size, err)
}
}
}
func FuzzVerify(f *testing.F) {
f.Add([]byte("invalid gzip"))
f.Add(verifyGzip(f, verifyTar(f, verifyFixture(f))))
f.Add(verifyTar(f, verifyFixture(f)))
f.Fuzz(func(t *testing.T, data []byte) {
if len(data) > 2<<20 {
t.Skip()
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
_, _ = Verify(ctx, bytes.NewReader(data), 4096)
// Fuzz tar/manifest parsing too, without every mutation first failing
// the outer gzip checksum.
_, _ = Verify(ctx, bytes.NewReader(verifyGzip(t, data)), 4096)
})
}