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 }