From 6618a9d7c262cb8328ecdafbed5fbe810db21386 Mon Sep 17 00:00:00 2001 From: mptyl Date: Sun, 16 Aug 2026 01:26:34 +0200 Subject: [PATCH] feat(cli): add no-mutation restore preflight --- tools/tht/internal/backup/preflight.go | 348 ++++++++++++++++++ tools/tht/internal/backup/preflight_test.go | 376 ++++++++++++++++++++ 2 files changed, 724 insertions(+) create mode 100644 tools/tht/internal/backup/preflight.go create mode 100644 tools/tht/internal/backup/preflight_test.go diff --git a/tools/tht/internal/backup/preflight.go b/tools/tht/internal/backup/preflight.go new file mode 100644 index 00000000..4e46232d --- /dev/null +++ b/tools/tht/internal/backup/preflight.go @@ -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 + } +} diff --git a/tools/tht/internal/backup/preflight_test.go b/tools/tht/internal/backup/preflight_test.go new file mode 100644 index 00000000..582ced74 --- /dev/null +++ b/tools/tht/internal/backup/preflight_test.go @@ -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) +}