Files
mrhid6 525dc6af00 feat: Add the backup archive writer and reader
Open extracts and verifies every member against the manifest before the
reader is usable, so a corrupt archive is refused before a restore writes
its first document rather than halfway through.
2026-09-07 11:10:17 +00:00

217 lines
5.0 KiB
Go

package backup
import (
"archive/tar"
"bytes"
"compress/gzip"
"errors"
"io"
"os"
"path/filepath"
"testing"
"time"
)
// writeSampleArchive builds a two-collection archive on disk and returns its path.
func writeSampleArchive(t *testing.T) string {
t.Helper()
path := filepath.Join(t.TempDir(), "sample.tar.gz")
f, err := os.Create(path)
if err != nil {
t.Fatalf("create: %v", err)
}
defer f.Close()
w := NewWriter(f)
servers, err := w.WriteCollection("servers", [][]byte{[]byte("one"), []byte("two")})
if err != nil {
t.Fatalf("WriteCollection: %v", err)
}
if err := w.WriteIndexes("servers", []byte(`[{"name":"idx"}]`)); err != nil {
t.Fatalf("WriteIndexes: %v", err)
}
keys, err := w.WriteCollection("keys", [][]byte{[]byte("k")})
if err != nil {
t.Fatalf("WriteCollection: %v", err)
}
if err := w.Close(Manifest{
FormatVersion: FormatVersion,
CreatedAt: time.Now().UTC(),
MongoDB: "vantage",
Collections: []CollectionEntry{servers, keys},
}); err != nil {
t.Fatalf("Close: %v", err)
}
return path
}
func TestWriterRecordsCountsAndChecksums(t *testing.T) {
var buf bytes.Buffer
w := NewWriter(&buf)
e, err := w.WriteCollection("servers", [][]byte{[]byte("one"), []byte("two")})
if err != nil {
t.Fatalf("WriteCollection: %v", err)
}
if e.Name != "servers" {
t.Fatalf("name %q", e.Name)
}
if e.Documents != 2 {
t.Fatalf("documents %d, want 2", e.Documents)
}
if e.Bytes != 6 {
t.Fatalf("bytes %d, want 6", e.Bytes)
}
if len(e.SHA256) != 64 {
t.Fatalf("sha256 %q is not 64 hex chars", e.SHA256)
}
}
func TestRoundTrip(t *testing.T) {
r, err := Open(writeSampleArchive(t))
if err != nil {
t.Fatalf("Open: %v", err)
}
defer r.Close()
if r.Manifest().MongoDB != "vantage" {
t.Fatalf("manifest not read back: %+v", r.Manifest())
}
rc, err := r.OpenCollection("servers")
if err != nil {
t.Fatalf("OpenCollection: %v", err)
}
defer rc.Close()
got, err := io.ReadAll(rc)
if err != nil {
t.Fatalf("read: %v", err)
}
if string(got) != "onetwo" {
t.Fatalf("got %q, want %q", got, "onetwo")
}
idx, err := r.IndexesJSON("servers")
if err != nil {
t.Fatalf("IndexesJSON: %v", err)
}
if string(idx) != `[{"name":"idx"}]` {
t.Fatalf("indexes round-tripped as %q", idx)
}
}
func TestIndexesJSONAbsentIsEmptyNotError(t *testing.T) {
r, err := Open(writeSampleArchive(t))
if err != nil {
t.Fatalf("Open: %v", err)
}
defer r.Close()
idx, err := r.IndexesJSON("keys")
if err != nil {
t.Fatalf("a collection with no index member must not error: %v", err)
}
if len(idx) != 0 {
t.Fatalf("want empty, got %q", idx)
}
}
func TestOpenRejectsCorruptedMember(t *testing.T) {
path := writeSampleArchive(t)
// Rewrite the archive with one byte of a collection member flipped, leaving
// the manifest's checksum describing the original.
corrupt := filepath.Join(t.TempDir(), "corrupt.tar.gz")
rewriteFlippingCollectionByte(t, path, corrupt, "servers")
if _, err := Open(corrupt); !errors.Is(err, ErrChecksum) {
t.Fatalf("got %v, want ErrChecksum", err)
}
}
func TestOpenRejectsUnknownFormatVersion(t *testing.T) {
path := filepath.Join(t.TempDir(), "future.tar.gz")
f, err := os.Create(path)
if err != nil {
t.Fatalf("create: %v", err)
}
w := NewWriter(f)
if err := w.Close(Manifest{FormatVersion: 99}); err != nil {
t.Fatalf("Close: %v", err)
}
f.Close()
if _, err := Open(path); !errors.Is(err, ErrUnknownFormat) {
t.Fatalf("got %v, want ErrUnknownFormat", err)
}
}
func TestCloseRemovesTempDir(t *testing.T) {
r, err := Open(writeSampleArchive(t))
if err != nil {
t.Fatalf("Open: %v", err)
}
dir := r.dir
if _, err := os.Stat(dir); err != nil {
t.Fatalf("temp dir missing while open: %v", err)
}
if err := r.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
if _, err := os.Stat(dir); !os.IsNotExist(err) {
t.Fatalf("temp dir %s survived Close", dir)
}
}
// rewriteFlippingCollectionByte copies an archive, flipping one byte inside the
// named collection's .bson member so its content no longer matches the checksum
// the manifest recorded.
func rewriteFlippingCollectionByte(t *testing.T, src, dst, collection string) {
t.Helper()
in, err := os.Open(src)
if err != nil {
t.Fatalf("open src: %v", err)
}
defer in.Close()
gz, err := gzip.NewReader(in)
if err != nil {
t.Fatalf("gzip: %v", err)
}
defer gz.Close()
out, err := os.Create(dst)
if err != nil {
t.Fatalf("create dst: %v", err)
}
defer out.Close()
gw := gzip.NewWriter(out)
defer gw.Close()
tw := tar.NewWriter(gw)
defer tw.Close()
tr := tar.NewReader(gz)
target := "collections/" + collection + ".bson"
for {
h, err := tr.Next()
if err == io.EOF {
return
}
if err != nil {
t.Fatalf("tar next: %v", err)
}
body, err := io.ReadAll(tr)
if err != nil {
t.Fatalf("read member: %v", err)
}
if h.Name == target && len(body) > 0 {
body[0] ^= 0xFF
}
h.Size = int64(len(body))
if err := tw.WriteHeader(h); err != nil {
t.Fatalf("write header: %v", err)
}
if _, err := tw.Write(body); err != nil {
t.Fatalf("write body: %v", err)
}
}
}