Files
ThothII/tools/tht/internal/backup/preflight.go
T

349 lines
12 KiB
Go

package backup
import (
"archive/tar"
"archive/zip"
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"hash"
"io"
"os"
"path"
"path/filepath"
"regexp"
"strings"
"github.com/aritmolab/thothii/tools/tht/internal/config"
)
var archiveDrivePath = regexp.MustCompile(`^[A-Za-z]:/`)
// PreflightRequest identifies an archive and the explicit protections required to inspect a
// restore that contains external secret payloads. Preflight never writes to the installation.
type PreflightRequest struct {
Archive string
Confirm bool
AllowExternalSecrets bool
}
// PreflightDependencies supplies checks that require knowledge of the current Docker targets.
// Part B can bind these callbacks to read-only Docker Compose and filesystem inspections before
// it starts its restore transaction.
type PreflightDependencies struct {
FreeBytes func(target string) (uint64, error)
CheckOwnershipPermissions func(context.Context, config.Installation, Manifest) error
CheckVolumeMapping func(context.Context, config.Installation, Manifest) error
CheckImageConfigCompatibility func(context.Context, config.Installation, Manifest) error
}
// ArchiveEntryMetadata is safe restore metadata. It intentionally contains no archive payload.
type ArchiveEntryMetadata struct {
Path string
Kind string
Owner string
Size int64
SHA256 string
Mode uint32
Sensitive bool
}
// PreflightResult is the validated, non-mutating input for a future restore transaction.
// Manifest and Entries contain checksums and ownership metadata only; no secret or other
// archive bytes are returned.
type PreflightResult struct {
ArchivePath string
ArchiveSize int64
RequiredBytes uint64
Manifest Manifest
Entries []ArchiveEntryMetadata
}
type inspectedArchiveEntry struct {
metadata ArchiveEntryMetadata
member *zip.File
}
// Preflight validates an archive completely before restore mutation. It streams each archive
// member once for checksum verification and never extracts a member to a destination.
func Preflight(ctx context.Context, installation config.Installation, request PreflightRequest, dependencies PreflightDependencies) (PreflightResult, error) {
if dependencies.FreeBytes == nil || dependencies.CheckOwnershipPermissions == nil ||
dependencies.CheckVolumeMapping == nil || dependencies.CheckImageConfigCompatibility == nil {
return PreflightResult{}, errors.New("backup preflight dependencies are incomplete")
}
if err := contextError(ctx); err != nil {
return PreflightResult{}, err
}
archivePath, archiveInfo, err := openableArchivePath(request.Archive)
if err != nil {
return PreflightResult{}, err
}
archiveFile, err := os.Open(archivePath)
if err != nil {
return PreflightResult{}, fmt.Errorf("open backup archive: %w", err)
}
defer archiveFile.Close()
reader, err := zip.NewReader(archiveFile, archiveInfo.Size())
if err != nil {
return PreflightResult{}, fmt.Errorf("read backup archive: %w", err)
}
manifestBytes, entries, err := inspectArchiveMembers(ctx, reader)
if err != nil {
return PreflightResult{}, err
}
if err := validateRawManifestPaths(manifestBytes); err != nil {
return PreflightResult{}, err
}
manifest, err := DecodeManifest(manifestBytes)
if err != nil {
return PreflightResult{}, fmt.Errorf("validate backup manifest: %w", err)
}
expectedID, err := backupInstallationID(installation)
if err != nil {
return PreflightResult{}, err
}
if manifest.InstallationID != expectedID {
return PreflightResult{}, errors.New("backup archive belongs to a different installation")
}
if manifest.IncludesSecrets && (!request.Confirm || !request.AllowExternalSecrets) {
return PreflightResult{}, errors.New("secret-bearing backup requires explicit confirmation and external-secret permission")
}
metadata, requiredBytes, err := reconcileArchiveEntries(ctx, manifest, entries)
if err != nil {
return PreflightResult{}, err
}
freeBytes, err := dependencies.FreeBytes(installation.ProjectDirectory)
if err != nil {
return PreflightResult{}, fmt.Errorf("check free disk space: %w", err)
}
if freeBytes < requiredBytes {
return PreflightResult{}, fmt.Errorf("insufficient free disk space for restore: need %d bytes, have %d", requiredBytes, freeBytes)
}
if err := contextError(ctx); err != nil {
return PreflightResult{}, err
}
if err := dependencies.CheckOwnershipPermissions(ctx, installation, manifest); err != nil {
return PreflightResult{}, fmt.Errorf("validate target ownership and permissions: %w", err)
}
if err := dependencies.CheckVolumeMapping(ctx, installation, manifest); err != nil {
return PreflightResult{}, fmt.Errorf("validate volume mapping: %w", err)
}
if err := dependencies.CheckImageConfigCompatibility(ctx, installation, manifest); err != nil {
return PreflightResult{}, fmt.Errorf("validate image/config compatibility: %w", err)
}
return PreflightResult{
ArchivePath: archivePath, ArchiveSize: archiveInfo.Size(), RequiredBytes: requiredBytes,
Manifest: manifest, Entries: metadata,
}, nil
}
func validateRawManifestPaths(value []byte) error {
var raw struct {
Entries []struct {
Path string `json:"path"`
} `json:"entries"`
}
decoder := json.NewDecoder(bytes.NewReader(value))
if err := decoder.Decode(&raw); err != nil {
return fmt.Errorf("validate backup manifest paths: %w", err)
}
for _, entry := range raw.Entries {
if _, err := validateArchiveMemberPath(entry.Path); err != nil {
return fmt.Errorf("validate backup manifest entry path: %w", err)
}
}
return nil
}
func openableArchivePath(requested string) (string, os.FileInfo, error) {
if strings.TrimSpace(requested) == "" {
return "", nil, errors.New("backup archive path is required")
}
archivePath, err := filepath.Abs(requested)
if err != nil {
return "", nil, fmt.Errorf("resolve backup archive path: %w", err)
}
archivePath = filepath.Clean(archivePath)
info, err := os.Lstat(archivePath)
if err != nil {
return "", nil, fmt.Errorf("inspect backup archive: %w", err)
}
if !info.Mode().IsRegular() {
return "", nil, errors.New("backup archive must be a regular file")
}
return archivePath, info, nil
}
func inspectArchiveMembers(ctx context.Context, reader *zip.Reader) ([]byte, map[string]inspectedArchiveEntry, error) {
var manifestBytes []byte
entries := make(map[string]inspectedArchiveEntry, len(reader.File))
for _, member := range reader.File {
if err := contextError(ctx); err != nil {
return nil, nil, err
}
name, err := validateArchiveMemberPath(member.Name)
if err != nil {
return nil, nil, err
}
if _, exists := entries[name]; exists || name == ManifestPath && manifestBytes != nil {
return nil, nil, fmt.Errorf("backup archive contains duplicate entry %q", name)
}
if member.Mode()&os.ModeSymlink != 0 {
return nil, nil, fmt.Errorf("backup archive contains a symlink entry %q", name)
}
if member.FileInfo().IsDir() {
return nil, nil, fmt.Errorf("backup archive contains unsupported directory entry %q", name)
}
opened, err := member.Open()
if err != nil {
return nil, nil, fmt.Errorf("open backup archive entry: %w", err)
}
if name == ManifestPath {
manifestBytes, err = io.ReadAll(io.LimitReader(opened, maxPreflightManifestBytes+1))
closeErr := opened.Close()
if err != nil {
return nil, nil, fmt.Errorf("read backup manifest: %w", err)
}
if closeErr != nil {
return nil, nil, fmt.Errorf("close backup manifest: %w", closeErr)
}
if int64(len(manifestBytes)) > maxPreflightManifestBytes {
return nil, nil, errors.New("backup manifest is too large")
}
continue
}
hashValue := sha256.New()
size, copyErr := io.Copy(hashValue, opened)
closeErr := opened.Close()
if copyErr != nil {
return nil, nil, fmt.Errorf("read backup archive entry: %w", copyErr)
}
if closeErr != nil {
return nil, nil, fmt.Errorf("close backup archive entry: %w", closeErr)
}
if size < 0 {
return nil, nil, errors.New("backup archive entry size overflow")
}
entries[name] = inspectedArchiveEntry{
metadata: ArchiveEntryMetadata{Path: name, Size: size, SHA256: digestForHash(hashValue), Mode: uint32(member.Mode().Perm())},
member: member,
}
}
if manifestBytes == nil {
return nil, nil, errors.New("backup archive is missing manifest.json")
}
return manifestBytes, entries, nil
}
const maxPreflightManifestBytes = 16 << 20
func validateArchiveMemberPath(value string) (string, error) {
if value == "" || strings.ContainsRune(value, '\x00') {
return "", errors.New("backup archive contains an invalid empty or NUL path")
}
if strings.ContainsRune(value, '\\') {
return "", fmt.Errorf("backup archive entry path %q uses an unsafe separator", value)
}
if strings.HasPrefix(value, "/") || strings.HasPrefix(value, "//") || archiveDrivePath.MatchString(value) {
return "", fmt.Errorf("backup archive entry path %q is absolute", value)
}
clean := path.Clean(value)
if clean != value || clean == "." || clean == ".." || strings.HasPrefix(clean, "../") {
return "", fmt.Errorf("backup archive entry path %q escapes the archive", value)
}
return clean, nil
}
func reconcileArchiveEntries(ctx context.Context, manifest Manifest, entries map[string]inspectedArchiveEntry) ([]ArchiveEntryMetadata, uint64, error) {
metadata := make([]ArchiveEntryMetadata, 0, len(entries))
var requiredBytes uint64
for _, entry := range manifest.Entries {
actual, present := entries[entry.Path]
if entry.Archived && !present {
return nil, 0, fmt.Errorf("backup archive is missing entry %q", entry.Path)
}
if !entry.Archived {
if present {
return nil, 0, fmt.Errorf("non-archived backup entry %q has a payload", entry.Path)
}
continue
}
if actual.metadata.Size != entry.Size || actual.metadata.SHA256 != entry.SHA256 {
return nil, 0, fmt.Errorf("checksum or size mismatch for backup entry %q", entry.Path)
}
if entry.Kind == EntryVolume {
if err := validateVolumeTar(ctx, actual.member); err != nil {
return nil, 0, fmt.Errorf("validate volume archive %q: %w", entry.Path, err)
}
}
if ^uint64(0)-requiredBytes < uint64(actual.metadata.Size) {
return nil, 0, errors.New("backup archive size overflows free-space calculation")
}
requiredBytes += uint64(actual.metadata.Size)
actual.metadata.Kind = entry.Kind
actual.metadata.Owner = entry.Owner
actual.metadata.Sensitive = entry.Sensitive
metadata = append(metadata, actual.metadata)
delete(entries, entry.Path)
}
if len(entries) != 0 {
for name := range entries {
return nil, 0, fmt.Errorf("backup archive contains unmanifested entry %q", name)
}
}
return metadata, requiredBytes, nil
}
func validateVolumeTar(ctx context.Context, member *zip.File) error {
if member == nil {
return errors.New("volume archive member is unavailable")
}
opened, err := member.Open()
if err != nil {
return fmt.Errorf("open volume archive: %w", err)
}
defer opened.Close()
reader := tar.NewReader(opened)
for {
if err := contextError(ctx); err != nil {
return err
}
header, err := reader.Next()
if errors.Is(err, io.EOF) {
return nil
}
if err != nil {
return fmt.Errorf("read volume archive: %w", err)
}
name := strings.TrimSuffix(header.Name, "/")
if _, err := validateArchiveMemberPath(name); err != nil {
return fmt.Errorf("volume archive path is unsafe: %w", err)
}
switch header.Typeflag {
case tar.TypeSymlink, tar.TypeLink:
return fmt.Errorf("volume archive contains a link entry %q", name)
case tar.TypeChar, tar.TypeBlock, tar.TypeFifo:
return fmt.Errorf("volume archive contains a special entry %q", name)
}
}
}
func digestForHash(value hash.Hash) string {
return "sha256:" + hex.EncodeToString(value.Sum(nil))
}
func contextError(ctx context.Context) error {
select {
case <-ctx.Done():
return ctx.Err()
default:
return nil
}
}