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 } }