854 lines
29 KiB
Go
854 lines
29 KiB
Go
package backup
|
|
|
|
import (
|
|
"archive/tar"
|
|
"archive/zip"
|
|
"bytes"
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/binary"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"hash"
|
|
"io"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/aritmolab/thothii/tools/tht/internal/config"
|
|
"github.com/aritmolab/thothii/tools/tht/internal/safeio"
|
|
)
|
|
|
|
var archiveDrivePath = regexp.MustCompile(`^[A-Za-z]:/`)
|
|
|
|
func newStagingArchiveName() (string, error) {
|
|
value := make([]byte, 16)
|
|
if _, err := rand.Read(value); err != nil {
|
|
return "", err
|
|
}
|
|
return "archive-" + hex.EncodeToString(value) + ".zip", nil
|
|
}
|
|
|
|
// 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
|
|
Limits PreflightLimits
|
|
}
|
|
|
|
// PreflightLimits bounds hostile archive processing. Zero values select the conservative
|
|
// defaults; callers that need larger installation backups must opt in explicitly.
|
|
type PreflightLimits struct {
|
|
MaxMembers int
|
|
MaxArchiveBytes uint64
|
|
MaxUncompressedBytes uint64
|
|
MaxCompressionRatio uint64
|
|
}
|
|
|
|
const (
|
|
defaultPreflightMaxMembers = 10_000
|
|
defaultPreflightMaxArchiveBytes = 32 << 30
|
|
defaultPreflightMaxUncompressedBytes = 128 << 30
|
|
defaultPreflightMaxCompressionRatio = 100
|
|
)
|
|
|
|
// 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
|
|
LogicalName string
|
|
SourcePath 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
|
|
archive *verifiedArchive
|
|
stagingRoot string
|
|
freeBytes func(string) (uint64, error)
|
|
}
|
|
|
|
type verifiedArchive struct {
|
|
file *os.File
|
|
info os.FileInfo
|
|
digest string
|
|
limits PreflightLimits
|
|
}
|
|
|
|
// stagedArchive holds an installation-private, immutable copy of the exact bytes accepted by
|
|
// Preflight. The original archive remains retained only for provenance revalidation.
|
|
type stagedArchive struct {
|
|
file *os.File
|
|
parent safeio.PrivateDirectoryHandle
|
|
lease *stagingRootLease
|
|
name string
|
|
path string
|
|
}
|
|
|
|
type stagingRootLease struct {
|
|
mu sync.Mutex
|
|
root string
|
|
parent safeio.PrivateDirectoryHandle
|
|
references int
|
|
}
|
|
|
|
func (lease *stagingRootLease) retain(root string) (safeio.PrivateDirectoryHandle, error) {
|
|
if lease == nil {
|
|
return nil, errors.New("private restore staging root is unavailable")
|
|
}
|
|
lease.mu.Lock()
|
|
defer lease.mu.Unlock()
|
|
if lease.parent == nil || lease.root != root || lease.parent.Validate() != nil {
|
|
return nil, errors.New("private restore staging root is unavailable")
|
|
}
|
|
lease.references++
|
|
return lease.parent, nil
|
|
}
|
|
|
|
func (lease *stagingRootLease) release() error {
|
|
if lease == nil {
|
|
return errors.New("private restore staging root is unavailable")
|
|
}
|
|
lease.mu.Lock()
|
|
if lease.references <= 0 || lease.parent == nil {
|
|
lease.mu.Unlock()
|
|
return errors.New("private restore staging root is unavailable")
|
|
}
|
|
lease.references--
|
|
if lease.references != 0 {
|
|
lease.mu.Unlock()
|
|
return nil
|
|
}
|
|
parent := lease.parent
|
|
lease.parent = nil
|
|
lease.mu.Unlock()
|
|
return parent.Close()
|
|
}
|
|
|
|
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
|
|
}
|
|
limits, err := normalizePreflightLimits(request.Limits)
|
|
if err != nil {
|
|
return PreflightResult{}, err
|
|
}
|
|
archivePath, archiveInfo, err := openableArchivePath(request.Archive)
|
|
if err != nil {
|
|
return PreflightResult{}, err
|
|
}
|
|
if uint64(archiveInfo.Size()) > limits.MaxArchiveBytes {
|
|
return PreflightResult{}, errors.New("backup archive exceeds the configured archive-size limit")
|
|
}
|
|
archiveFile, err := os.Open(archivePath)
|
|
if err != nil {
|
|
return PreflightResult{}, fmt.Errorf("open backup archive: %w", err)
|
|
}
|
|
keepArchiveOpen := false
|
|
defer func() {
|
|
if !keepArchiveOpen {
|
|
_ = archiveFile.Close()
|
|
}
|
|
}()
|
|
openedInfo, err := archiveFile.Stat()
|
|
if err != nil {
|
|
return PreflightResult{}, fmt.Errorf("inspect opened backup archive: %w", err)
|
|
}
|
|
if !openedInfo.Mode().IsRegular() || openedInfo.Size() != archiveInfo.Size() {
|
|
return PreflightResult{}, errors.New("backup archive changed while opening")
|
|
}
|
|
memberCount, err := zipMemberCount(archiveFile, openedInfo.Size())
|
|
if err != nil {
|
|
return PreflightResult{}, err
|
|
}
|
|
if memberCount > uint64(limits.MaxMembers) {
|
|
return PreflightResult{}, errors.New("backup archive exceeds the configured member limit")
|
|
}
|
|
initialDigest, err := digestArchive(ctx, archiveFile, limits.MaxArchiveBytes)
|
|
if err != nil {
|
|
return PreflightResult{}, err
|
|
}
|
|
reader, err := zip.NewReader(archiveFile, openedInfo.Size())
|
|
if err != nil {
|
|
return PreflightResult{}, fmt.Errorf("read backup archive: %w", err)
|
|
}
|
|
|
|
manifestBytes, entries, err := inspectArchiveMembers(ctx, reader, limits)
|
|
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
|
|
}
|
|
stagingBytes := uint64(openedInfo.Size())
|
|
if stagingBytes > ^uint64(0)-requiredBytes {
|
|
return PreflightResult{}, errors.New("restore staging requirement exceeds supported size")
|
|
}
|
|
requiredWithStaging := requiredBytes + stagingBytes
|
|
stagingCapacityPath := installation.ControlDirectory()
|
|
freeBytes, err := dependencies.FreeBytes(stagingCapacityPath)
|
|
if err != nil {
|
|
return PreflightResult{}, fmt.Errorf("check free disk space: %w", err)
|
|
}
|
|
if freeBytes < requiredWithStaging {
|
|
return PreflightResult{}, fmt.Errorf("insufficient free disk space for restore: need %d bytes, have %d", requiredWithStaging, 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)
|
|
}
|
|
finalDigest, err := digestArchive(ctx, archiveFile, limits.MaxArchiveBytes)
|
|
if err != nil {
|
|
return PreflightResult{}, err
|
|
}
|
|
if initialDigest != finalDigest {
|
|
return PreflightResult{}, errors.New("backup archive changed during preflight")
|
|
}
|
|
keepArchiveOpen = true
|
|
return PreflightResult{
|
|
ArchivePath: archivePath, ArchiveSize: openedInfo.Size(), RequiredBytes: requiredBytes,
|
|
Manifest: manifest, Entries: metadata,
|
|
archive: &verifiedArchive{file: archiveFile, info: openedInfo, digest: finalDigest, limits: limits},
|
|
stagingRoot: filepath.Join(stagingCapacityPath, "restore-staging"),
|
|
freeBytes: dependencies.FreeBytes,
|
|
}, nil
|
|
}
|
|
|
|
// RevalidateArchive binds a restore to the bytes inspected by Preflight. A caller must invoke
|
|
// it immediately before a restore transaction and use the returned retained handle, never reopen
|
|
// ArchivePath. It refuses a path replacement or in-place content change.
|
|
func (result PreflightResult) RevalidateArchive() (*os.File, error) {
|
|
return result.revalidateArchive(context.Background())
|
|
}
|
|
|
|
func (result PreflightResult) revalidateArchive(ctx context.Context) (*os.File, error) {
|
|
if result.archive == nil || result.archive.file == nil {
|
|
return nil, errors.New("backup archive has not been retained by preflight")
|
|
}
|
|
pathInfo, err := os.Lstat(result.ArchivePath)
|
|
if err != nil {
|
|
return nil, errors.New("backup archive changed after preflight")
|
|
}
|
|
if !pathInfo.Mode().IsRegular() || !os.SameFile(result.archive.info, pathInfo) {
|
|
return nil, errors.New("backup archive changed after preflight")
|
|
}
|
|
heldInfo, err := result.archive.file.Stat()
|
|
if err != nil || !os.SameFile(result.archive.info, heldInfo) || heldInfo.Size() != result.ArchiveSize {
|
|
return nil, errors.New("backup archive changed after preflight")
|
|
}
|
|
digest, err := digestArchive(ctx, result.archive.file, result.archive.limits.MaxArchiveBytes)
|
|
if err != nil || digest != result.archive.digest {
|
|
return nil, errors.New("backup archive changed after preflight")
|
|
}
|
|
return result.archive.file, nil
|
|
}
|
|
|
|
// StageArchive revalidates the retained archive and copies its exact bytes into a private file
|
|
// immediately before extraction. Later writes to the source archive cannot affect extraction.
|
|
func (result PreflightResult) StageArchive(ctx context.Context) (_ *stagedArchive, resultErr error) {
|
|
return result.stageArchive(ctx, nil)
|
|
}
|
|
|
|
// stageArchiveAlongside reserves another immutable staging file through an already-retained
|
|
// root capability. Windows no-delete handles intentionally prevent reopening that root while a
|
|
// candidate stage is live, so restore shares the capability without widening share flags.
|
|
func (result PreflightResult) stageArchiveAlongside(ctx context.Context, existing *stagedArchive) (_ *stagedArchive, resultErr error) {
|
|
if existing == nil || existing.lease == nil {
|
|
return nil, errors.New("private restore staging root is unavailable")
|
|
}
|
|
return result.stageArchive(ctx, existing.lease)
|
|
}
|
|
|
|
func (result PreflightResult) stageArchive(ctx context.Context, existing *stagingRootLease) (_ *stagedArchive, resultErr error) {
|
|
source, err := result.revalidateArchive(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if result.stagingRoot == "" || result.freeBytes == nil {
|
|
return nil, errors.New("backup archive has no controlled staging reservation")
|
|
}
|
|
var (
|
|
parent safeio.PrivateDirectoryHandle
|
|
lease *stagingRootLease
|
|
)
|
|
if existing == nil {
|
|
if err := safeio.EnsurePrivateDirectory(result.stagingRoot); err != nil {
|
|
return nil, fmt.Errorf("create private restore staging root: %w", err)
|
|
}
|
|
var found bool
|
|
parent, found, err = safeio.OpenPrivateDirectory(result.stagingRoot, true)
|
|
if err != nil || !found {
|
|
if parent != nil {
|
|
_ = parent.Close()
|
|
}
|
|
return nil, errors.New("open private restore staging root")
|
|
}
|
|
lease = &stagingRootLease{root: result.stagingRoot, parent: parent, references: 1}
|
|
} else {
|
|
lease = existing
|
|
parent, err = lease.retain(result.stagingRoot)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
releaseLease := true
|
|
defer func() {
|
|
if releaseLease {
|
|
_ = lease.release()
|
|
}
|
|
}()
|
|
freeBytes, err := result.freeBytes(result.stagingRoot)
|
|
if err != nil {
|
|
return nil, errors.New("check private restore staging capacity")
|
|
}
|
|
if result.ArchiveSize < 0 || freeBytes < uint64(result.ArchiveSize) {
|
|
return nil, errors.New("insufficient free disk space for private restore staging archive")
|
|
}
|
|
var (
|
|
file *os.File
|
|
name string
|
|
)
|
|
for attempt := 0; attempt < 8; attempt++ {
|
|
name, err = newStagingArchiveName()
|
|
if err != nil {
|
|
return nil, errors.New("create private restore staging archive")
|
|
}
|
|
var created bool
|
|
file, created, err = parent.CreateRegularFile(name)
|
|
if err != nil {
|
|
return nil, errors.New("create private restore staging archive")
|
|
}
|
|
if created {
|
|
break
|
|
}
|
|
file = nil
|
|
}
|
|
if file == nil {
|
|
return nil, errors.New("create private restore staging archive")
|
|
}
|
|
staged := &stagedArchive{
|
|
file: file,
|
|
parent: parent,
|
|
lease: lease,
|
|
name: name,
|
|
path: filepath.Join(result.stagingRoot, name),
|
|
}
|
|
releaseLease = false
|
|
completed := false
|
|
defer func() {
|
|
if !completed {
|
|
_ = staged.Close()
|
|
}
|
|
}()
|
|
if _, err := source.Seek(0, io.SeekStart); err != nil {
|
|
return nil, errors.New("seek verified backup archive for staging")
|
|
}
|
|
digest := sha256.New()
|
|
buffer := make([]byte, 128*1024)
|
|
var total int64
|
|
for {
|
|
if err := contextError(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
count, readErr := source.Read(buffer)
|
|
if count > 0 {
|
|
if int64(count) > result.ArchiveSize-total {
|
|
return nil, errors.New("backup archive changed after preflight")
|
|
}
|
|
written, writeErr := file.Write(buffer[:count])
|
|
if writeErr != nil || written != count {
|
|
return nil, errors.New("write private restore staging archive")
|
|
}
|
|
if _, writeErr := digest.Write(buffer[:count]); writeErr != nil {
|
|
return nil, errors.New("hash private restore staging archive")
|
|
}
|
|
total += int64(count)
|
|
}
|
|
if errors.Is(readErr, io.EOF) {
|
|
break
|
|
}
|
|
if readErr != nil {
|
|
return nil, errors.New("read verified backup archive for staging")
|
|
}
|
|
}
|
|
if total != result.ArchiveSize || digestForHash(digest) != result.archive.digest {
|
|
return nil, errors.New("backup archive changed after preflight")
|
|
}
|
|
if err := file.Sync(); err != nil {
|
|
return nil, errors.New("sync private restore staging archive")
|
|
}
|
|
if _, err := file.Seek(0, io.SeekStart); err != nil {
|
|
return nil, errors.New("rewind private restore staging archive")
|
|
}
|
|
completed = true
|
|
return staged, nil
|
|
}
|
|
|
|
// Close removes only the staging file created by StageArchive through its retained directory.
|
|
func (staged *stagedArchive) Close() error {
|
|
if staged == nil {
|
|
return nil
|
|
}
|
|
var failed bool
|
|
if staged.file != nil {
|
|
if err := staged.file.Close(); err != nil {
|
|
failed = true
|
|
}
|
|
staged.file = nil
|
|
}
|
|
if staged.parent != nil {
|
|
safeio.NotifyPrivateDirectoryTestHookForTest("before-stage-archive-remove")
|
|
removed, err := staged.parent.RemoveRegular(staged.name)
|
|
if err != nil || !removed {
|
|
failed = true
|
|
}
|
|
if staged.lease == nil || staged.lease.release() != nil {
|
|
failed = true
|
|
}
|
|
staged.parent = nil
|
|
staged.lease = nil
|
|
}
|
|
staged.name = ""
|
|
staged.path = ""
|
|
if failed {
|
|
return errors.New("destroy private restore staging archive")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// CloseArchive releases the retained read-only archive handle after the caller finishes the
|
|
// restore transaction or decides not to proceed.
|
|
func (result PreflightResult) CloseArchive() error {
|
|
if result.archive == nil || result.archive.file == nil {
|
|
return nil
|
|
}
|
|
return result.archive.file.Close()
|
|
}
|
|
|
|
func normalizePreflightLimits(requested PreflightLimits) (PreflightLimits, error) {
|
|
if requested.MaxMembers < 0 {
|
|
return PreflightLimits{}, errors.New("backup preflight member limit must be positive")
|
|
}
|
|
if requested.MaxMembers == 0 {
|
|
requested.MaxMembers = defaultPreflightMaxMembers
|
|
}
|
|
if requested.MaxArchiveBytes == 0 {
|
|
requested.MaxArchiveBytes = defaultPreflightMaxArchiveBytes
|
|
}
|
|
if requested.MaxUncompressedBytes == 0 {
|
|
requested.MaxUncompressedBytes = defaultPreflightMaxUncompressedBytes
|
|
}
|
|
if requested.MaxCompressionRatio == 0 {
|
|
requested.MaxCompressionRatio = defaultPreflightMaxCompressionRatio
|
|
}
|
|
return requested, 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, limits PreflightLimits) ([]byte, map[string]inspectedArchiveEntry, error) {
|
|
if len(reader.File) > limits.MaxMembers {
|
|
return nil, nil, errors.New("backup archive exceeds the configured member limit")
|
|
}
|
|
var manifestBytes []byte
|
|
entries := make(map[string]inspectedArchiveEntry, len(reader.File))
|
|
var totalUncompressed uint64
|
|
for _, member := range reader.File {
|
|
if err := contextError(ctx); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if err := checkArchiveMemberLimits(member, limits, &totalUncompressed); 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")
|
|
}
|
|
if uint64(len(manifestBytes)) != member.UncompressedSize64 {
|
|
return nil, nil, errors.New("backup manifest size does not match its archive metadata")
|
|
}
|
|
continue
|
|
}
|
|
hashValue := sha256.New()
|
|
size, copyErr := io.Copy(hashValue, io.LimitReader(opened, int64(member.UncompressedSize64)+1))
|
|
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")
|
|
}
|
|
if uint64(size) != member.UncompressedSize64 {
|
|
return nil, nil, errors.New("backup archive entry size does not match its archive metadata")
|
|
}
|
|
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
|
|
}
|
|
|
|
func checkArchiveMemberLimits(member *zip.File, limits PreflightLimits, total *uint64) error {
|
|
if member.UncompressedSize64 > limits.MaxUncompressedBytes || *total > limits.MaxUncompressedBytes-member.UncompressedSize64 {
|
|
return errors.New("backup archive exceeds the configured uncompressed-size limit")
|
|
}
|
|
*total += member.UncompressedSize64
|
|
if member.CompressedSize64 == 0 {
|
|
if member.UncompressedSize64 != 0 {
|
|
return errors.New("backup archive exceeds the configured compression-ratio limit")
|
|
}
|
|
return nil
|
|
}
|
|
if limits.MaxCompressionRatio <= ^uint64(0)/member.CompressedSize64 &&
|
|
member.UncompressedSize64 > limits.MaxCompressionRatio*member.CompressedSize64 {
|
|
return errors.New("backup archive exceeds the configured compression-ratio limit")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func digestArchive(ctx context.Context, file *os.File, maximum uint64) (string, error) {
|
|
if _, err := file.Seek(0, io.SeekStart); err != nil {
|
|
return "", fmt.Errorf("seek backup archive: %w", err)
|
|
}
|
|
digest := sha256.New()
|
|
buffer := make([]byte, 128*1024)
|
|
var total uint64
|
|
for {
|
|
if err := contextError(ctx); err != nil {
|
|
return "", err
|
|
}
|
|
count, err := file.Read(buffer)
|
|
if count > 0 {
|
|
if uint64(count) > maximum-total {
|
|
return "", errors.New("backup archive exceeds the configured archive-size limit")
|
|
}
|
|
total += uint64(count)
|
|
if _, writeErr := digest.Write(buffer[:count]); writeErr != nil {
|
|
return "", fmt.Errorf("hash backup archive: %w", writeErr)
|
|
}
|
|
}
|
|
if errors.Is(err, io.EOF) {
|
|
break
|
|
}
|
|
if err != nil {
|
|
return "", fmt.Errorf("read backup archive: %w", err)
|
|
}
|
|
}
|
|
if _, err := file.Seek(0, io.SeekStart); err != nil {
|
|
return "", fmt.Errorf("rewind backup archive: %w", err)
|
|
}
|
|
return digestForHash(digest), nil
|
|
}
|
|
|
|
func zipMemberCount(file *os.File, size int64) (uint64, error) {
|
|
const (
|
|
endOfCentralDirectorySignature = 0x06054b50
|
|
zip64LocatorSignature = 0x07064b50
|
|
zip64EndSignature = 0x06064b50
|
|
endOfCentralDirectorySize = 22
|
|
zipCommentMaximum = 1<<16 - 1
|
|
zip64LocatorSize = 20
|
|
zip64EndMinimumSize = 56
|
|
)
|
|
if size < endOfCentralDirectorySize {
|
|
return 0, errors.New("read backup archive directory: archive is too small")
|
|
}
|
|
tailSize := int64(endOfCentralDirectorySize + zipCommentMaximum)
|
|
if size < tailSize {
|
|
tailSize = size
|
|
}
|
|
tail := make([]byte, tailSize)
|
|
if _, err := file.ReadAt(tail, size-tailSize); err != nil {
|
|
return 0, fmt.Errorf("read backup archive directory: %w", err)
|
|
}
|
|
for index := len(tail) - endOfCentralDirectorySize; index >= 0; index-- {
|
|
if binary.LittleEndian.Uint32(tail[index:index+4]) != endOfCentralDirectorySignature {
|
|
continue
|
|
}
|
|
commentLength := int(binary.LittleEndian.Uint16(tail[index+20 : index+22]))
|
|
if index+endOfCentralDirectorySize+commentLength != len(tail) {
|
|
continue
|
|
}
|
|
count := uint64(binary.LittleEndian.Uint16(tail[index+10 : index+12]))
|
|
if count != 0xffff {
|
|
return count, nil
|
|
}
|
|
endOffset := size - tailSize + int64(index)
|
|
if endOffset < zip64LocatorSize {
|
|
return 0, errors.New("read backup archive directory: ZIP64 locator is missing")
|
|
}
|
|
locator := make([]byte, zip64LocatorSize)
|
|
if _, err := file.ReadAt(locator, endOffset-zip64LocatorSize); err != nil {
|
|
return 0, fmt.Errorf("read backup archive directory: %w", err)
|
|
}
|
|
if binary.LittleEndian.Uint32(locator[:4]) != zip64LocatorSignature {
|
|
return 0, errors.New("read backup archive directory: ZIP64 locator is invalid")
|
|
}
|
|
zip64Offset := binary.LittleEndian.Uint64(locator[8:16])
|
|
if size < zip64EndMinimumSize || zip64Offset > uint64(size-zip64EndMinimumSize) {
|
|
return 0, errors.New("read backup archive directory: ZIP64 record is invalid")
|
|
}
|
|
zip64End := make([]byte, zip64EndMinimumSize)
|
|
if _, err := file.ReadAt(zip64End, int64(zip64Offset)); err != nil {
|
|
return 0, fmt.Errorf("read backup archive directory: %w", err)
|
|
}
|
|
if binary.LittleEndian.Uint32(zip64End[:4]) != zip64EndSignature || binary.LittleEndian.Uint64(zip64End[4:12]) < 44 {
|
|
return 0, errors.New("read backup archive directory: ZIP64 record is invalid")
|
|
}
|
|
return binary.LittleEndian.Uint64(zip64End[32:40]), nil
|
|
}
|
|
return 0, errors.New("read backup archive directory: end record is missing")
|
|
}
|
|
|
|
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 actual.metadata.Mode != entry.Mode {
|
|
return nil, 0, fmt.Errorf("mode 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.LogicalName = entry.LogicalName
|
|
actual.metadata.SourcePath = entry.SourcePath
|
|
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 name == "." {
|
|
if header.Typeflag != tar.TypeDir {
|
|
return errors.New("volume archive root marker is not a directory")
|
|
}
|
|
continue
|
|
}
|
|
name = strings.TrimPrefix(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
|
|
}
|
|
}
|