package backup import ( "errors" "fmt" "strings" "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") // ErrBadCollectionName is returned for a manifest naming a collection that // cannot safely be used as a path component. var ErrBadCollectionName = errors.New("manifest names an unusable collection") // 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) } // Collection names become path components inside the extraction directory, // and an archive is operator-supplied input that may not be one we wrote. for _, c := range m.Collections { if err := checkCollectionName(c.Name); err != nil { return err } } return nil } // checkCollectionName refuses a name that could escape a directory when joined // as a path component. func checkCollectionName(name string) error { switch { case name == "": return fmt.Errorf("%w: a collection entry has no name", ErrBadCollectionName) case name == "." || name == "..": return fmt.Errorf("%w: %q", ErrBadCollectionName, name) case strings.ContainsAny(name, "/\\"), strings.Contains(name, ".."): return fmt.Errorf("%w: %q", ErrBadCollectionName, name) } 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. // // settings is not in the list: it holds no encrypted material. Its ESO read // token is a SHA-256 hash, not ciphertext. func CiphertextCollections() []string { return []string{"keys", "secrets", "auth_providers", "console_sessions"} }