diff --git a/backup/manifest.go b/backup/manifest.go new file mode 100644 index 0000000..91246c0 --- /dev/null +++ b/backup/manifest.go @@ -0,0 +1,73 @@ +package backup + +import ( + "errors" + "fmt" + "time" +) + +// FormatVersion is the archive format this build reads and writes. Restore +// refuses anything else rather than guessing at a layout it does not know. +const FormatVersion = 1 + +// ManifestName is the archive member holding the manifest. +const ManifestName = "manifest.json" + +// ErrUnknownFormat is returned for an archive this build cannot read. +var ErrUnknownFormat = errors.New("unsupported archive format version") + +// CollectionEntry describes one collection in the archive. Bytes and SHA256 +// cover the uncompressed .bson member, which is what restore verifies before +// writing anything. +type CollectionEntry struct { + Name string `json:"name"` + Documents int64 `json:"documents"` + Bytes int64 `json:"bytes"` + SHA256 string `json:"sha256"` +} + +// Manifest is the archive's index and its provenance. +// +// KeyFingerprint is a pointer so "this archive recorded no key" is a distinct +// state from "this archive recorded the empty string". A null here is a real +// condition an operator must be told about, not a default. +type Manifest struct { + FormatVersion int `json:"format_version"` + CreatedAt time.Time `json:"created_at"` + VantageVersion string `json:"vantage_version"` + Hostname string `json:"hostname"` + MongoDB string `json:"mongo_db"` + MongoServerVersion string `json:"mongo_server_version"` + KeyFingerprint *string `json:"key_fingerprint"` + Collections []CollectionEntry `json:"collections"` + Excluded []string `json:"excluded"` +} + +// Check validates what can be validated without reading the rest of the archive. +func (m Manifest) Check() error { + if m.FormatVersion != FormatVersion { + return fmt.Errorf("%w: archive is version %d, this build reads version %d", + ErrUnknownFormat, m.FormatVersion, FormatVersion) + } + return nil +} + +// Collection looks up one entry by name. +func (m Manifest) Collection(name string) (CollectionEntry, bool) { + for _, c := range m.Collections { + if c.Name == name { + return c, true + } + } + return CollectionEntry{}, false +} + +// CiphertextCollections names the collections holding AES-GCM ciphertext. +// +// It exists to be printed. When a restore proceeds under a key that does not +// match the archive, this is the list of what will be unreadable afterwards, +// and an operator deserves to see it before the write rather than discover it +// a week later. +func CiphertextCollections() []string { + return []string{"keys", "secrets", "auth_providers", "console_sessions", "settings"} +} diff --git a/backup/manifest_test.go b/backup/manifest_test.go new file mode 100644 index 0000000..b4676cb --- /dev/null +++ b/backup/manifest_test.go @@ -0,0 +1,81 @@ +package backup + +import ( + "encoding/json" + "errors" + "strings" + "testing" + "time" +) + +func TestManifestJSONShape(t *testing.T) { + fp := "abc" + m := Manifest{ + FormatVersion: FormatVersion, + CreatedAt: time.Date(2026, 9, 7, 12, 0, 0, 0, time.UTC), + VantageVersion: "dev", + Hostname: "box", + MongoDB: "vantage", + MongoServerVersion: "7.0.5", + KeyFingerprint: &fp, + Collections: []CollectionEntry{{Name: "servers", Documents: 3, Bytes: 120, SHA256: "dead"}}, + Excluded: []string{"audit_logs"}, + } + raw, err := json.Marshal(m) + if err != nil { + t.Fatalf("marshal: %v", err) + } + for _, want := range []string{ + `"format_version":1`, `"created_at":"2026-09-07T12:00:00Z"`, + `"key_fingerprint":"abc"`, `"mongo_server_version":"7.0.5"`, + `"excluded":["audit_logs"]`, + } { + if !strings.Contains(string(raw), want) { + t.Fatalf("manifest JSON missing %s\ngot: %s", want, raw) + } + } +} + +func TestManifestNullFingerprint(t *testing.T) { + raw, err := json.Marshal(Manifest{FormatVersion: FormatVersion}) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if !strings.Contains(string(raw), `"key_fingerprint":null`) { + t.Fatalf("absent key must marshal as null, got: %s", raw) + } +} + +func TestManifestCheckRejectsOtherVersions(t *testing.T) { + if err := (Manifest{FormatVersion: FormatVersion}).Check(); err != nil { + t.Fatalf("current version rejected: %v", err) + } + for _, v := range []int{0, 2, 99} { + if err := (Manifest{FormatVersion: v}).Check(); !errors.Is(err, ErrUnknownFormat) { + t.Fatalf("version %d: got %v, want ErrUnknownFormat", v, err) + } + } +} + +func TestManifestCollectionLookup(t *testing.T) { + m := Manifest{Collections: []CollectionEntry{{Name: "keys", Documents: 1}}} + if _, ok := m.Collection("keys"); !ok { + t.Fatal("known collection not found") + } + if _, ok := m.Collection("nope"); ok { + t.Fatal("unknown collection reported as found") + } +} + +func TestCiphertextCollections(t *testing.T) { + got := CiphertextCollections() + want := []string{"keys", "secrets", "auth_providers", "console_sessions", "settings"} + if len(got) != len(want) { + t.Fatalf("got %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("got %v, want %v", got, want) + } + } +}