mirror of
https://github.com/MeshCore-Beacon/beacon-server.git
synced 2026-10-16 13:49:56 +00:00
* feat(backup): export bounded database and saved-config bundles * test(backup): preserve synthetic channel keys in exports * fix(backup): check writable file closure before publication
233 lines
6.8 KiB
Go
233 lines
6.8 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"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
const testSQL = "CREATE TABLE fixture (id bigint PRIMARY KEY, name text);\nINSERT INTO fixture VALUES (1, 'café');\n"
|
|
|
|
// TestDumpProcess supplies an actual subprocess, including failure and cancellation.
|
|
func TestDumpProcess(t *testing.T) {
|
|
mode := os.Getenv("BEACON_BACKUP_TEST_PROCESS")
|
|
if mode == "" {
|
|
return
|
|
}
|
|
switch mode {
|
|
case "ok":
|
|
_, _ = io.WriteString(os.Stdout, testSQL)
|
|
case "fail":
|
|
_, _ = io.WriteString(os.Stdout, "incomplete dump")
|
|
_, _ = io.WriteString(os.Stderr, "PRIVATE_PASSWORD_CANARY")
|
|
os.Exit(17)
|
|
case "large":
|
|
_, _ = os.Stdout.Write(bytes.Repeat([]byte("x"), 65536))
|
|
case "hang":
|
|
_, _ = io.WriteString(os.Stdout, "incomplete dump")
|
|
time.Sleep(time.Minute)
|
|
case "empty":
|
|
default:
|
|
os.Exit(2)
|
|
}
|
|
os.Exit(0)
|
|
}
|
|
|
|
func helper(t *testing.T, mode string) func(context.Context) *exec.Cmd {
|
|
t.Helper()
|
|
exe, err := os.Executable()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return func(ctx context.Context) *exec.Cmd {
|
|
cmd := exec.CommandContext(ctx, exe, "-test.run=^TestDumpProcess$")
|
|
cmd.Env = append(os.Environ(), "BEACON_BACKUP_TEST_PROCESS="+mode)
|
|
return cmd
|
|
}
|
|
}
|
|
|
|
func setup(t *testing.T) Options {
|
|
t.Helper()
|
|
t.Setenv("PGDATABASE", "fixture")
|
|
dir := t.TempDir()
|
|
opts := Options{ConfigPath: filepath.Join(dir, "saved.yaml"), OutputPath: filepath.Join(dir, "backup.tar.gz"),
|
|
MaxBytes: int64(len(testSQL)), Timeout: 10 * time.Second, Version: "test-revision"}
|
|
if err := os.WriteFile(opts.ConfigPath, []byte("# preserve comments and this synthetic key\nchannel_keys:\n keys:\n '00': {key: '00000000000000000000000000000000', name: fixture}\n"), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() {
|
|
paths, err := filepath.Glob(filepath.Join(dir, ".beacon-backup-*"))
|
|
if err != nil || len(paths) != 0 {
|
|
t.Errorf("private staging was not cleaned: %v %v", paths, err)
|
|
}
|
|
})
|
|
return opts
|
|
}
|
|
|
|
func TestExportBundle(t *testing.T) {
|
|
opts := setup(t)
|
|
if err := export(context.Background(), opts, helper(t, "ok")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
f, err := os.Open(opts.OutputPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer f.Close()
|
|
info, err := f.Stat()
|
|
if err != nil || (runtime.GOOS != "windows" && info.Mode().Perm() != 0600) {
|
|
t.Fatalf("backup must be private: %v %v", info, err)
|
|
}
|
|
gz, err := gzip.NewReader(f)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer gz.Close()
|
|
tr := tar.NewReader(gz)
|
|
files := map[string][]byte{}
|
|
for {
|
|
header, err := tr.Next()
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if err != nil || header.Mode != 0600 || header.Typeflag != tar.TypeReg {
|
|
t.Fatalf("invalid archive member: %v %v", header, err)
|
|
}
|
|
if _, duplicate := files[header.Name]; duplicate {
|
|
t.Fatal("duplicate archive name")
|
|
}
|
|
files[header.Name], err = io.ReadAll(tr)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if _, err := io.Copy(io.Discard, gz); err != nil {
|
|
t.Fatal("invalid gzip footer:", err)
|
|
}
|
|
config, _ := os.ReadFile(opts.ConfigPath)
|
|
if len(files) != 3 || string(files["database.sql"]) != testSQL || !bytes.Equal(files["config.yaml"], config) {
|
|
t.Fatal("payloads differ from the supplied SQL and saved config")
|
|
}
|
|
var manifest Manifest
|
|
if err := json.Unmarshal(files["manifest.json"], &manifest); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if manifest.FormatVersion != 1 || manifest.ToolVersion != opts.Version || manifest.CreatedAt.IsZero() || len(manifest.Files) != 2 || len(manifest.Excluded) != 5 {
|
|
t.Fatalf("incomplete manifest: %+v", manifest)
|
|
}
|
|
for _, member := range manifest.Files {
|
|
digest := sha256.Sum256(files[member.Name])
|
|
if member.Size != int64(len(files[member.Name])) || member.SHA256 != hex.EncodeToString(digest[:]) {
|
|
t.Errorf("manifest mismatch for %s", member.Name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestExportFailureCleanup(t *testing.T) {
|
|
for _, mode := range []string{"fail", "large", "hang", "empty"} {
|
|
t.Run(mode, func(t *testing.T) {
|
|
opts := setup(t)
|
|
if mode == "hang" {
|
|
opts.Timeout = time.Second
|
|
}
|
|
err := export(context.Background(), opts, helper(t, mode))
|
|
if err == nil || strings.Contains(err.Error(), "PRIVATE_PASSWORD_CANARY") {
|
|
t.Fatalf("expected sanitized failure, got %v", err)
|
|
}
|
|
if mode == "large" && !errors.Is(err, ErrTooLarge) {
|
|
t.Fatalf("wrong limit error: %v", err)
|
|
}
|
|
if mode == "hang" && !errors.Is(err, context.DeadlineExceeded) {
|
|
t.Fatalf("wrong timeout error: %v", err)
|
|
}
|
|
if _, err := os.Lstat(opts.OutputPath); !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatal("failed export published an output")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExportNeverReplacesDestination(t *testing.T) {
|
|
for _, race := range []bool{false, true} {
|
|
t.Run(map[bool]string{false: "existing", true: "created-during-export"}[race], func(t *testing.T) {
|
|
opts := setup(t)
|
|
putExisting := func() {
|
|
if err := os.WriteFile(opts.OutputPath, []byte("retain this backup"), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if !race {
|
|
putExisting()
|
|
}
|
|
cmd := helper(t, "ok")
|
|
err := export(context.Background(), opts, func(ctx context.Context) *exec.Cmd {
|
|
if !race {
|
|
t.Fatal("existing output must be rejected before starting pg_dump")
|
|
}
|
|
putExisting()
|
|
return cmd(ctx)
|
|
})
|
|
data, readErr := os.ReadFile(opts.OutputPath)
|
|
if err == nil || readErr != nil || string(data) != "retain this backup" {
|
|
t.Fatalf("existing backup changed: %v %v", err, readErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExportValidation(t *testing.T) {
|
|
for _, mode := range []string{"database", "limit", "config", "canceled"} {
|
|
t.Run(mode, func(t *testing.T) {
|
|
opts := setup(t)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
switch mode {
|
|
case "database":
|
|
t.Setenv("PGDATABASE", "")
|
|
case "limit":
|
|
opts.MaxBytes = 0
|
|
case "config":
|
|
if err := os.WriteFile(opts.ConfigPath, make([]byte, maxConfigBytes+1), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
case "canceled":
|
|
cancel()
|
|
}
|
|
if err := export(ctx, opts, func(context.Context) *exec.Cmd { t.Fatal("dump ran on invalid input"); return nil }); err == nil {
|
|
t.Fatal("invalid export succeeded")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDumpArgumentsExcludeConnectionSecrets(t *testing.T) {
|
|
t.Setenv("PGPASSWORD", "PRIVATE_PASSWORD_CANARY")
|
|
t.Setenv("POSTGRES_DSN", "postgres://user:PRIVATE_PASSWORD_CANARY@localhost/db")
|
|
cmd := dumpCommand(context.Background())
|
|
if strings.Contains(strings.Join(cmd.Args, " "), "PRIVATE_PASSWORD_CANARY") {
|
|
t.Fatal("credentials reached command arguments")
|
|
}
|
|
args := strings.Join(cmd.Args, " ")
|
|
for _, required := range []string{"--no-password", "--lock-wait-timeout=5000", "--no-owner", "--no-acl", "--no-tablespaces", "--format=plain"} {
|
|
if !strings.Contains(args, required) {
|
|
t.Errorf("missing dump boundary: %s", required)
|
|
}
|
|
}
|
|
}
|