feat(cli): add no-mutation restore preflight
This commit is contained in:
@@ -0,0 +1,348 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"archive/zip"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/config"
|
||||
)
|
||||
|
||||
func TestPreflightReturnsValidatedMetadataAndCallsAllTargetChecksWithoutExtracting(t *testing.T) {
|
||||
installation := preflightTestInstallation(t)
|
||||
archive := filepath.Join(t.TempDir(), "valid.zip")
|
||||
writePreflightArchive(t, archive, preflightArchiveSpec{
|
||||
entries: []preflightArchiveEntry{{path: "configuration/operator.env", body: []byte("safe")}},
|
||||
})
|
||||
|
||||
checks := make([]string, 0, 3)
|
||||
result, err := Preflight(context.Background(), installation, PreflightRequest{Archive: archive, Confirm: true}, PreflightDependencies{
|
||||
FreeBytes: func(string) (uint64, error) { return 1024, nil },
|
||||
CheckOwnershipPermissions: func(context.Context, config.Installation, Manifest) error {
|
||||
checks = append(checks, "ownership")
|
||||
return nil
|
||||
},
|
||||
CheckVolumeMapping: func(context.Context, config.Installation, Manifest) error {
|
||||
checks = append(checks, "volumes")
|
||||
return nil
|
||||
},
|
||||
CheckImageConfigCompatibility: func(context.Context, config.Installation, Manifest) error {
|
||||
checks = append(checks, "images")
|
||||
return nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Manifest.InstallationID != "local-dev" || result.Manifest.Entries[0].Path != "configuration/operator.env" {
|
||||
t.Fatalf("validated metadata = %#v", result)
|
||||
}
|
||||
if result.ArchivePath != archive || result.RequiredBytes != 4 || len(result.Entries) != 1 {
|
||||
t.Fatalf("archive metadata = %#v", result)
|
||||
}
|
||||
if strings.Join(checks, ",") != "ownership,volumes,images" {
|
||||
t.Fatalf("target checks = %v", checks)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(installation.ProjectDirectory, "configuration", "operator.env")); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("preflight extracted into a final target: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightRejectsAdversarialArchiveEntriesAndManifestIdentity(t *testing.T) {
|
||||
installation := preflightTestInstallation(t)
|
||||
tests := []struct {
|
||||
name string
|
||||
make func(string)
|
||||
}{
|
||||
{
|
||||
name: "traversal",
|
||||
make: func(path string) {
|
||||
writePreflightArchive(t, path, preflightArchiveSpec{rawEntries: []preflightRawArchiveEntry{{path: "../outside", body: []byte("x")}}})
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "absolute",
|
||||
make: func(path string) {
|
||||
writePreflightArchive(t, path, preflightArchiveSpec{rawEntries: []preflightRawArchiveEntry{{path: "/outside", body: []byte("x")}}})
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "windows absolute",
|
||||
make: func(path string) {
|
||||
writePreflightArchive(t, path, preflightArchiveSpec{rawEntries: []preflightRawArchiveEntry{{path: `C:\outside`, body: []byte("x")}}})
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "normalized traversal",
|
||||
make: func(path string) {
|
||||
writePreflightArchive(t, path, preflightArchiveSpec{
|
||||
entries: []preflightArchiveEntry{{path: "target", body: []byte("x")}},
|
||||
rawManifest: []byte(`{"schema_version":1,"installation_id":"local-dev","created_at":"2026-08-16T10:00:00Z","source_revision":"` + testRevision + `","includes_secrets":false,"compose_project":"thothii-test","images":[],"volumes":[],"entries":[{"path":"foo/../target","kind":"file","owner":"installation","sha256":"` + digestForPreflight([]byte("x")) + `","size":1,"archived":true}]}`),
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "symlink",
|
||||
make: func(path string) {
|
||||
writePreflightArchive(t, path, preflightArchiveSpec{
|
||||
entries: []preflightArchiveEntry{{path: "link", body: []byte("target"), symlink: true}},
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "duplicate",
|
||||
make: func(path string) {
|
||||
writePreflightArchive(t, path, preflightArchiveSpec{
|
||||
entries: []preflightArchiveEntry{{path: "file", body: []byte("one")}},
|
||||
rawEntries: []preflightRawArchiveEntry{{path: "file", body: []byte("two")}},
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "checksum mismatch",
|
||||
make: func(path string) {
|
||||
writePreflightArchive(t, path, preflightArchiveSpec{
|
||||
entries: []preflightArchiveEntry{{path: "file", body: []byte("actual"), checksum: "sha256:" + strings.Repeat("0", 64)}},
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "unknown schema",
|
||||
make: func(path string) {
|
||||
writePreflightArchive(t, path, preflightArchiveSpec{schemaVersion: CurrentSchemaVersion + 1})
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "wrong installation",
|
||||
make: func(path string) {
|
||||
writePreflightArchive(t, path, preflightArchiveSpec{installationID: "another-installation"})
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
archive := filepath.Join(t.TempDir(), test.name+".zip")
|
||||
test.make(archive)
|
||||
_, err := Preflight(context.Background(), installation, PreflightRequest{Archive: archive, Confirm: true}, permissivePreflightDependencies())
|
||||
if err == nil {
|
||||
t.Fatal("Preflight accepted adversarial archive")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightRequiresExplicitProtectionForExternalSecretPayloadsWithoutLeakingIt(t *testing.T) {
|
||||
installation := preflightTestInstallation(t)
|
||||
secret := []byte("never-print-this-secret")
|
||||
archive := filepath.Join(t.TempDir(), "secrets.zip")
|
||||
writePreflightArchive(t, archive, preflightArchiveSpec{
|
||||
includeSecrets: true,
|
||||
entries: []preflightArchiveEntry{{path: "external-secrets/000", body: secret, kind: EntryExternalSecret, sensitive: true}},
|
||||
})
|
||||
|
||||
_, err := Preflight(context.Background(), installation, PreflightRequest{Archive: archive, Confirm: false}, permissivePreflightDependencies())
|
||||
if err == nil || strings.Contains(err.Error(), string(secret)) || !strings.Contains(err.Error(), "confirmation") {
|
||||
t.Fatalf("secret policy error = %v", err)
|
||||
}
|
||||
_, err = Preflight(context.Background(), installation, PreflightRequest{Archive: archive, Confirm: true, AllowExternalSecrets: true}, permissivePreflightDependencies())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightRejectsInsufficientDiskAndEachTargetCompatibilityFailure(t *testing.T) {
|
||||
installation := preflightTestInstallation(t)
|
||||
archive := filepath.Join(t.TempDir(), "checked.zip")
|
||||
writePreflightArchive(t, archive, preflightArchiveSpec{entries: []preflightArchiveEntry{{path: "large", body: []byte("123456789")}}})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
deps func(error) PreflightDependencies
|
||||
want string
|
||||
}{
|
||||
{name: "disk", deps: func(error) PreflightDependencies {
|
||||
deps := permissivePreflightDependencies()
|
||||
deps.FreeBytes = func(string) (uint64, error) { return 1, nil }
|
||||
return deps
|
||||
}, want: "free disk"},
|
||||
{name: "ownership", deps: func(want error) PreflightDependencies {
|
||||
deps := permissivePreflightDependencies()
|
||||
deps.CheckOwnershipPermissions = func(context.Context, config.Installation, Manifest) error { return want }
|
||||
return deps
|
||||
}, want: "ownership"},
|
||||
{name: "volume mapping", deps: func(want error) PreflightDependencies {
|
||||
deps := permissivePreflightDependencies()
|
||||
deps.CheckVolumeMapping = func(context.Context, config.Installation, Manifest) error { return want }
|
||||
return deps
|
||||
}, want: "volume mapping"},
|
||||
{name: "image config", deps: func(want error) PreflightDependencies {
|
||||
deps := permissivePreflightDependencies()
|
||||
deps.CheckImageConfigCompatibility = func(context.Context, config.Installation, Manifest) error { return want }
|
||||
return deps
|
||||
}, want: "image config"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
want := errors.New(test.want + " rejected")
|
||||
_, err := Preflight(context.Background(), installation, PreflightRequest{Archive: archive, Confirm: true}, test.deps(want))
|
||||
if err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("Preflight() error = %v, want %q", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightRejectsTraversalAndSymlinkInsideVolumeTar(t *testing.T) {
|
||||
installation := preflightTestInstallation(t)
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
header tar.Header
|
||||
}{
|
||||
{name: "traversal", header: tar.Header{Name: "../outside", Mode: 0o600, Size: 1, Typeflag: tar.TypeReg}},
|
||||
{name: "symlink", header: tar.Header{Name: "link", Mode: 0o777, Typeflag: tar.TypeSymlink, Linkname: "../../outside"}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var payload strings.Builder
|
||||
writer := tar.NewWriter(&stringWriter{value: &payload})
|
||||
if err := writer.WriteHeader(&test.header); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if test.header.Size > 0 {
|
||||
if _, err := writer.Write([]byte("x")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
archive := filepath.Join(t.TempDir(), test.name+".zip")
|
||||
writePreflightArchive(t, archive, preflightArchiveSpec{entries: []preflightArchiveEntry{{path: "volumes/sessions.tar", body: []byte(payload.String()), kind: EntryVolume}}})
|
||||
if _, err := Preflight(context.Background(), installation, PreflightRequest{Archive: archive, Confirm: true}, permissivePreflightDependencies()); err == nil {
|
||||
t.Fatal("Preflight accepted unsafe TAR member")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type preflightArchiveSpec struct {
|
||||
installationID string
|
||||
schemaVersion int
|
||||
includeSecrets bool
|
||||
entries []preflightArchiveEntry
|
||||
rawEntries []preflightRawArchiveEntry
|
||||
rawManifest []byte
|
||||
}
|
||||
|
||||
type preflightArchiveEntry struct {
|
||||
path string
|
||||
body []byte
|
||||
checksum string
|
||||
kind string
|
||||
sensitive bool
|
||||
symlink bool
|
||||
}
|
||||
|
||||
type preflightRawArchiveEntry struct {
|
||||
path string
|
||||
body []byte
|
||||
}
|
||||
|
||||
func preflightTestInstallation(t *testing.T) config.Installation {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
return config.Installation{
|
||||
Path: filepath.Join(root, "deploy", "local-dev", "thothii-installation.yaml"),
|
||||
ProjectDirectory: root,
|
||||
}
|
||||
}
|
||||
|
||||
func permissivePreflightDependencies() PreflightDependencies {
|
||||
return PreflightDependencies{
|
||||
FreeBytes: func(string) (uint64, error) { return 1 << 30, nil },
|
||||
CheckOwnershipPermissions: func(context.Context, config.Installation, Manifest) error { return nil },
|
||||
CheckVolumeMapping: func(context.Context, config.Installation, Manifest) error { return nil },
|
||||
CheckImageConfigCompatibility: func(context.Context, config.Installation, Manifest) error { return nil },
|
||||
}
|
||||
}
|
||||
|
||||
func writePreflightArchive(t *testing.T, archivePath string, spec preflightArchiveSpec) {
|
||||
t.Helper()
|
||||
if spec.installationID == "" {
|
||||
spec.installationID = "local-dev"
|
||||
}
|
||||
if spec.schemaVersion == 0 {
|
||||
spec.schemaVersion = CurrentSchemaVersion
|
||||
}
|
||||
manifest := Manifest{
|
||||
SchemaVersion: spec.schemaVersion, InstallationID: spec.installationID,
|
||||
CreatedAt: time.Date(2026, 8, 16, 10, 0, 0, 0, time.UTC), SourceRevision: testRevision,
|
||||
IncludesSecrets: spec.includeSecrets, ComposeProject: "thothii-test",
|
||||
}
|
||||
for _, entry := range spec.entries {
|
||||
checksum := entry.checksum
|
||||
if checksum == "" {
|
||||
checksum = digestForPreflight(entry.body)
|
||||
}
|
||||
kind := entry.kind
|
||||
if kind == "" {
|
||||
kind = EntryFile
|
||||
}
|
||||
sourcePath := ""
|
||||
owner := "installation"
|
||||
if kind == EntryExternalSecret {
|
||||
sourcePath = "/protected/secret"
|
||||
owner = "external-secret"
|
||||
}
|
||||
manifest.Entries = append(manifest.Entries, Entry{Path: entry.path, Kind: kind, Owner: owner, SourcePath: sourcePath, SHA256: checksum, Size: int64(len(entry.body)), Archived: true, Sensitive: entry.sensitive})
|
||||
}
|
||||
manifestBytes, err := manifest.JSON()
|
||||
if err != nil {
|
||||
if spec.schemaVersion != CurrentSchemaVersion || spec.installationID != "local-dev" {
|
||||
manifestBytes = rawPreflightManifest(spec)
|
||||
} else {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if spec.rawManifest != nil {
|
||||
manifestBytes = spec.rawManifest
|
||||
}
|
||||
file, err := os.Create(archivePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer file.Close()
|
||||
writer := zip.NewWriter(file)
|
||||
for _, entry := range spec.entries {
|
||||
header := &zip.FileHeader{Name: entry.path, Method: zip.Store}
|
||||
if entry.symlink {
|
||||
header.SetMode(os.ModeSymlink | 0o777)
|
||||
}
|
||||
created, err := writer.CreateHeader(header)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := created.Write(entry.body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, entry := range spec.rawEntries {
|
||||
created, err := writer.CreateHeader(&zip.FileHeader{Name: entry.path, Method: zip.Store})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := created.Write(entry.body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
created, err := writer.Create(ManifestPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := created.Write(manifestBytes); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func rawPreflightManifest(spec preflightArchiveSpec) []byte {
|
||||
return []byte(`{"schema_version":999,"installation_id":"` + spec.installationID + `","created_at":"2026-08-16T10:00:00Z","source_revision":"` + testRevision + `","includes_secrets":false,"compose_project":"thothii-test","images":[],"volumes":[],"entries":[]}`)
|
||||
}
|
||||
|
||||
func digestForPreflight(value []byte) string {
|
||||
digest := sha256.Sum256(value)
|
||||
return "sha256:" + hex.EncodeToString(digest[:])
|
||||
}
|
||||
|
||||
type stringWriter struct {
|
||||
value *strings.Builder
|
||||
}
|
||||
|
||||
func (writer *stringWriter) Write(value []byte) (int, error) {
|
||||
return writer.value.Write(value)
|
||||
}
|
||||
Reference in New Issue
Block a user