Files

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
}