218 lines
7.9 KiB
Go
218 lines
7.9 KiB
Go
package backup
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"path"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
CurrentSchemaVersion = 1
|
|
ManifestPath = "manifest.json"
|
|
|
|
EntryFile = "file"
|
|
EntryVolume = "volume"
|
|
EntrySecretReference = "external-secret-reference"
|
|
EntryExternalSecret = "external-secret"
|
|
EntryPreservationReference = "preservation-root-reference"
|
|
)
|
|
|
|
var (
|
|
checksumPattern = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`)
|
|
revisionPattern = regexp.MustCompile(`^[0-9a-f]{40}([0-9a-f]{24})?$`)
|
|
)
|
|
|
|
// Entry describes one logical backup payload or one external prerequisite.
|
|
type Entry struct {
|
|
Path string `json:"path"`
|
|
Kind string `json:"kind"`
|
|
Owner string `json:"owner"`
|
|
LogicalName string `json:"logical_name,omitempty"`
|
|
SourcePath string `json:"source_path,omitempty"`
|
|
SHA256 string `json:"sha256"`
|
|
Size int64 `json:"size"`
|
|
Mode uint32 `json:"mode,omitempty"`
|
|
Archived bool `json:"archived"`
|
|
Sensitive bool `json:"sensitive,omitempty"`
|
|
}
|
|
|
|
// ImageIdentity records the configured image reference and, when available, the local image ID.
|
|
type ImageIdentity struct {
|
|
Service string `json:"service"`
|
|
Reference string `json:"reference"`
|
|
ID string `json:"id,omitempty"`
|
|
}
|
|
|
|
// VolumeMetadata binds a logical Compose volume to its installation-owned Docker identity.
|
|
type VolumeMetadata struct {
|
|
LogicalName string `json:"logical_name"`
|
|
Name string `json:"name"`
|
|
Driver string `json:"driver"`
|
|
Labels map[string]string `json:"labels,omitempty"`
|
|
}
|
|
|
|
// Manifest is the versioned restore contract written as the last archive member.
|
|
type Manifest struct {
|
|
SchemaVersion int `json:"schema_version"`
|
|
InstallationID string `json:"installation_id"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
SourceRevision string `json:"source_revision"`
|
|
IncludesSecrets bool `json:"includes_secrets"`
|
|
ComposeProject string `json:"compose_project"`
|
|
Images []ImageIdentity `json:"images"`
|
|
Volumes []VolumeMetadata `json:"volumes"`
|
|
Entries []Entry `json:"entries"`
|
|
}
|
|
|
|
// DigestBytes returns the manifest's canonical checksum representation.
|
|
func DigestBytes(value []byte) string {
|
|
digest := sha256.Sum256(value)
|
|
return "sha256:" + hex.EncodeToString(digest[:])
|
|
}
|
|
|
|
// Finalize normalizes archive paths, orders every repeated field and validates the schema.
|
|
func (manifest *Manifest) Finalize() error {
|
|
manifest.CreatedAt = manifest.CreatedAt.UTC()
|
|
for index := range manifest.Entries {
|
|
normalized, err := normalizeArchivePath(manifest.Entries[index].Path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
manifest.Entries[index].Path = normalized
|
|
}
|
|
sort.Slice(manifest.Entries, func(left, right int) bool {
|
|
if manifest.Entries[left].Path != manifest.Entries[right].Path {
|
|
return manifest.Entries[left].Path < manifest.Entries[right].Path
|
|
}
|
|
if manifest.Entries[left].Kind != manifest.Entries[right].Kind {
|
|
return manifest.Entries[left].Kind < manifest.Entries[right].Kind
|
|
}
|
|
return manifest.Entries[left].Owner < manifest.Entries[right].Owner
|
|
})
|
|
sort.Slice(manifest.Images, func(left, right int) bool { return manifest.Images[left].Service < manifest.Images[right].Service })
|
|
sort.Slice(manifest.Volumes, func(left, right int) bool {
|
|
return manifest.Volumes[left].LogicalName < manifest.Volumes[right].LogicalName
|
|
})
|
|
return manifest.Validate()
|
|
}
|
|
|
|
// Validate rejects unsupported or unsafe restore contracts.
|
|
func (manifest Manifest) Validate() error {
|
|
if manifest.SchemaVersion != CurrentSchemaVersion {
|
|
return fmt.Errorf("unsupported backup manifest schema version %d", manifest.SchemaVersion)
|
|
}
|
|
if strings.TrimSpace(manifest.InstallationID) == "" || strings.ContainsAny(manifest.InstallationID, `/\\`) {
|
|
return errors.New("backup manifest installation ID is invalid")
|
|
}
|
|
if manifest.CreatedAt.IsZero() || manifest.CreatedAt.Location() != time.UTC {
|
|
return errors.New("backup manifest creation time must be UTC")
|
|
}
|
|
if !revisionPattern.MatchString(manifest.SourceRevision) {
|
|
return errors.New("backup manifest source revision is invalid")
|
|
}
|
|
if strings.TrimSpace(manifest.ComposeProject) == "" {
|
|
return errors.New("backup manifest Compose project is missing")
|
|
}
|
|
seenPaths := make(map[string]struct{}, len(manifest.Entries))
|
|
includedExternalSecrets := 0
|
|
for _, entry := range manifest.Entries {
|
|
if _, err := normalizeArchivePath(entry.Path); err != nil {
|
|
return err
|
|
}
|
|
if _, exists := seenPaths[entry.Path]; exists {
|
|
return fmt.Errorf("backup manifest contains duplicate entry path %q", entry.Path)
|
|
}
|
|
seenPaths[entry.Path] = struct{}{}
|
|
if entry.Owner == "" || entry.Kind == "" || entry.Size < 0 || !checksumPattern.MatchString(entry.SHA256) {
|
|
return fmt.Errorf("backup manifest entry %q is invalid", entry.Path)
|
|
}
|
|
if (entry.Kind == EntrySecretReference || entry.Kind == EntryExternalSecret) && entry.SourcePath == "" {
|
|
return fmt.Errorf("backup manifest external secret entry %q has no source path", entry.Path)
|
|
}
|
|
if entry.Kind == EntryExternalSecret {
|
|
if !entry.Archived || !entry.Sensitive {
|
|
return fmt.Errorf("backup manifest external secret entry %q is not marked sensitive and archived", entry.Path)
|
|
}
|
|
includedExternalSecrets++
|
|
}
|
|
}
|
|
if manifest.IncludesSecrets != (includedExternalSecrets > 0) {
|
|
return errors.New("backup manifest external secret marker does not match included external secret payloads")
|
|
}
|
|
seenImages := make(map[string]struct{}, len(manifest.Images))
|
|
for _, image := range manifest.Images {
|
|
if image.Service == "" || image.Reference == "" {
|
|
return errors.New("backup manifest image identity is incomplete")
|
|
}
|
|
if _, exists := seenImages[image.Service]; exists {
|
|
return fmt.Errorf("backup manifest contains duplicate image service %q", image.Service)
|
|
}
|
|
seenImages[image.Service] = struct{}{}
|
|
}
|
|
seenVolumes := make(map[string]struct{}, len(manifest.Volumes))
|
|
for _, volume := range manifest.Volumes {
|
|
if volume.LogicalName == "" || volume.Name == "" || volume.Driver == "" {
|
|
return errors.New("backup manifest volume metadata is incomplete")
|
|
}
|
|
if _, exists := seenVolumes[volume.LogicalName]; exists {
|
|
return fmt.Errorf("backup manifest contains duplicate volume %q", volume.LogicalName)
|
|
}
|
|
seenVolumes[volume.LogicalName] = struct{}{}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// JSON returns a deterministic, indented representation after validating a copy.
|
|
func (manifest Manifest) JSON() ([]byte, error) {
|
|
manifest.Entries = append([]Entry(nil), manifest.Entries...)
|
|
manifest.Images = append([]ImageIdentity(nil), manifest.Images...)
|
|
manifest.Volumes = append([]VolumeMetadata(nil), manifest.Volumes...)
|
|
if err := manifest.Finalize(); err != nil {
|
|
return nil, err
|
|
}
|
|
value, err := json.MarshalIndent(manifest, "", " ")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return append(value, '\n'), nil
|
|
}
|
|
|
|
// DecodeManifest decodes exactly one supported manifest document.
|
|
func DecodeManifest(value []byte) (Manifest, error) {
|
|
decoder := json.NewDecoder(bytes.NewReader(value))
|
|
decoder.DisallowUnknownFields()
|
|
var manifest Manifest
|
|
if err := decoder.Decode(&manifest); err != nil {
|
|
return Manifest{}, fmt.Errorf("decode backup manifest: %w", err)
|
|
}
|
|
var trailing any
|
|
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
|
|
return Manifest{}, errors.New("backup manifest contains trailing data")
|
|
}
|
|
if err := manifest.Finalize(); err != nil {
|
|
return Manifest{}, err
|
|
}
|
|
return manifest, nil
|
|
}
|
|
|
|
func normalizeArchivePath(value string) (string, error) {
|
|
value = strings.ReplaceAll(value, `\`, "/")
|
|
if value == "" || strings.HasPrefix(value, "/") {
|
|
return "", errors.New("backup manifest entry path is empty or absolute")
|
|
}
|
|
normalized := path.Clean(value)
|
|
if normalized == "." || normalized == ".." || strings.HasPrefix(normalized, "../") {
|
|
return "", fmt.Errorf("backup manifest entry path %q escapes the archive", value)
|
|
}
|
|
return normalized, nil
|
|
}
|