fix(backup): retain staging cleanup capability

This commit is contained in:
2026-08-18 10:50:56 +02:00
parent d43738eeae
commit 5f9a3ae066
9 changed files with 349 additions and 27 deletions
+56 -22
View File
@@ -5,6 +5,7 @@ import (
"archive/zip"
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
@@ -25,6 +26,14 @@ import (
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 {
@@ -97,9 +106,10 @@ type verifiedArchive struct {
// 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
path string
directory string
file *os.File
parent safeio.PrivateDirectoryHandle
name string
path string
}
type inspectedArchiveEntry struct {
@@ -278,21 +288,44 @@ func (result PreflightResult) StageArchive(ctx context.Context) (_ *stagedArchiv
if result.ArchiveSize < 0 || freeBytes < uint64(result.ArchiveSize) {
return nil, errors.New("insufficient free disk space for private restore staging archive")
}
directory, err := os.MkdirTemp(result.stagingRoot, "archive-")
if err != nil {
return nil, errors.New("create private restore staging directory")
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")
}
if err := safeio.ProtectPrivateDirectory(directory); err != nil {
_ = os.Remove(directory)
return nil, errors.New("protect private restore staging directory")
var (
file *os.File
name string
)
for attempt := 0; attempt < 8; attempt++ {
name, err = newStagingArchiveName()
if err != nil {
_ = parent.Close()
return nil, errors.New("create private restore staging archive")
}
var created bool
file, created, err = parent.CreateRegularFile(name)
if err != nil {
_ = parent.Close()
return nil, errors.New("create private restore staging archive")
}
if created {
break
}
file = nil
}
path := filepath.Join(directory, "archive.zip")
file, err := safeio.CreateCanonicalNewPrivateFile(path)
if err != nil {
_ = os.Remove(directory)
if file == nil {
_ = parent.Close()
return nil, errors.New("create private restore staging archive")
}
staged := &stagedArchive{file: file, path: path, directory: directory}
staged := &stagedArchive{
file: file,
parent: parent,
name: name,
path: filepath.Join(result.stagingRoot, name),
}
completed := false
defer func() {
if !completed {
@@ -343,7 +376,7 @@ func (result PreflightResult) StageArchive(ctx context.Context) (_ *stagedArchiv
return staged, nil
}
// Close removes only the staging file and directory created by StageArchive.
// Close removes only the staging file created by StageArchive through its retained directory.
func (staged *stagedArchive) Close() error {
if staged == nil {
return nil
@@ -355,18 +388,19 @@ func (staged *stagedArchive) Close() error {
}
staged.file = nil
}
if staged.path != "" {
if err := os.Remove(staged.path); err != nil && !errors.Is(err, os.ErrNotExist) {
if staged.parent != nil {
safeio.NotifyPrivateDirectoryTestHookForTest("before-stage-archive-remove")
removed, err := staged.parent.RemoveRegular(staged.name)
if err != nil || !removed {
failed = true
}
staged.path = ""
}
if staged.directory != "" {
if err := os.Remove(staged.directory); err != nil && !errors.Is(err, os.ErrNotExist) {
if err := staged.parent.Close(); err != nil {
failed = true
}
staged.directory = ""
staged.parent = nil
}
staged.name = ""
staged.path = ""
if failed {
return errors.New("destroy private restore staging archive")
}
+2 -2
View File
@@ -95,8 +95,8 @@ func TestPreflightStagesOnTheAccountedInstallationFilesystemInsteadOfTMPDIR(t *t
t.Fatalf("free-space targets = %q, want installation control directory %q", capacityTargets, installation.ControlDirectory())
}
stagingRoot := filepath.Join(installation.ControlDirectory(), "restore-staging")
if relative, err := filepath.Rel(stagingRoot, staged.directory); err != nil || relative == "." || strings.HasPrefix(relative, "..") {
t.Fatalf("staging directory = %q, want a child of %q", staged.directory, stagingRoot)
if filepath.Dir(staged.path) != result.stagingRoot {
t.Fatalf("staging archive directory = %q, want direct child directory %q", filepath.Dir(staged.path), stagingRoot)
}
}
@@ -4,6 +4,7 @@ package backup
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
@@ -79,3 +80,78 @@ func TestStageArchiveCreatesUnixPrivateRegularFile(t *testing.T) {
t.Fatalf("staged archive mode = %#o, want 0600", got)
}
}
func TestStageArchiveCloseUsesPinnedRootAfterAncestorSwap(t *testing.T) {
installation := preflightTestInstallation(t)
if err := os.MkdirAll(installation.ControlDirectory(), 0o700); err != nil {
t.Fatal(err)
}
archive := filepath.Join(t.TempDir(), "valid.zip")
writePreflightArchive(t, archive, preflightArchiveSpec{
entries: []preflightArchiveEntry{{path: "configuration/operator.env", body: []byte("safe")}},
})
result, err := Preflight(context.Background(), installation, PreflightRequest{Archive: archive, Confirm: true}, permissivePreflightDependencies())
if err != nil {
t.Fatal(err)
}
defer result.CloseArchive()
outsideParent, err := filepath.EvalSymlinks(t.TempDir())
if err != nil {
t.Fatal(err)
}
outside := filepath.Join(outsideParent, "outside")
if err := os.Mkdir(outside, 0o700); err != nil {
t.Fatal(err)
}
if err := safeio.ProtectPrivateDirectory(outside); err != nil {
t.Fatal(err)
}
sentinelPath := filepath.Join(outside, "sentinel")
sentinel := []byte("outside sentinel")
if err := os.WriteFile(sentinelPath, sentinel, 0o600); err != nil {
t.Fatal(err)
}
swapped := false
restoreHook := safeio.SetPrivateDirectoryTestHookForTest(func(stage string) {
if stage != "before-stage-archive-remove" || swapped {
return
}
swapped = true
movedRoot := result.stagingRoot + "-original"
if err := os.Rename(result.stagingRoot, movedRoot); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outside, result.stagingRoot); err != nil {
t.Fatal(err)
}
})
defer restoreHook()
staged, err := result.StageArchive(context.Background())
if err != nil {
t.Fatal(err)
}
stagedName := staged.name
movedRoot := result.stagingRoot + "-original"
if filepath.Dir(staged.path) != result.stagingRoot {
t.Fatalf("staged archive directory = %q, want %q", filepath.Dir(staged.path), result.stagingRoot)
}
if err := staged.Close(); err != nil {
t.Fatal(err)
}
if !swapped {
t.Fatal("stage archive cleanup hook did not run")
}
if _, err := os.Stat(filepath.Join(movedRoot, stagedName)); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("moved staging archive = %v, want os.ErrNotExist", err)
}
gotSentinel, err := os.ReadFile(sentinelPath)
if err != nil {
t.Fatal(err)
}
if string(gotSentinel) != string(sentinel) {
t.Fatalf("outside sentinel = %q, want %q", gotSentinel, sentinel)
}
}
@@ -4,6 +4,7 @@ package backup
import (
"context"
"errors"
"os"
"path/filepath"
"testing"
@@ -34,10 +35,77 @@ func TestStageArchiveProtectsWindowsStagingArtifactsWithOwnerOnlyACLs(t *testing
if err := safeio.ValidatePrivateDirectory(result.stagingRoot); err != nil {
t.Fatalf("staging root ACL = %v, want owner-only", err)
}
if err := safeio.ValidatePrivateDirectory(staged.directory); err != nil {
t.Fatalf("staged archive directory ACL = %v, want owner-only", err)
}
if err := safeio.ValidatePrivateRegular(staged.path); err != nil {
t.Fatalf("staged archive ACL = %v, want owner-only", err)
}
}
func TestStageArchiveCloseUsesPinnedRootAfterAncestorSwap(t *testing.T) {
installation := preflightTestInstallation(t)
if err := os.MkdirAll(installation.ControlDirectory(), 0o700); err != nil {
t.Fatal(err)
}
archive := filepath.Join(t.TempDir(), "valid.zip")
writePreflightArchive(t, archive, preflightArchiveSpec{
entries: []preflightArchiveEntry{{path: "configuration/operator.env", body: []byte("safe")}},
})
result, err := Preflight(context.Background(), installation, PreflightRequest{Archive: archive, Confirm: true}, permissivePreflightDependencies())
if err != nil {
t.Fatal(err)
}
defer result.CloseArchive()
outsideParent, err := filepath.EvalSymlinks(t.TempDir())
if err != nil {
t.Fatal(err)
}
outside := filepath.Join(outsideParent, "outside")
if err := os.Mkdir(outside, 0o700); err != nil {
t.Fatal(err)
}
if err := safeio.ProtectPrivateDirectory(outside); err != nil {
t.Fatal(err)
}
sentinelPath := filepath.Join(outside, "sentinel")
sentinel := []byte("outside sentinel")
if err := os.WriteFile(sentinelPath, sentinel, 0o600); err != nil {
t.Fatal(err)
}
attemptedSwap := false
restoreHook := safeio.SetPrivateDirectoryTestHookForTest(func(stage string) {
if stage != "before-stage-archive-remove" || attemptedSwap {
return
}
attemptedSwap = true
if err := os.Rename(result.stagingRoot, result.stagingRoot+"-moved"); err == nil {
t.Fatal("staging-root rename succeeded while Close retained its directory handle")
}
})
defer restoreHook()
staged, err := result.StageArchive(context.Background())
if err != nil {
t.Fatal(err)
}
stagedName := staged.name
if filepath.Dir(staged.path) != result.stagingRoot {
t.Fatalf("staged archive directory = %q, want %q", filepath.Dir(staged.path), result.stagingRoot)
}
if err := staged.Close(); err != nil {
t.Fatal(err)
}
if !attemptedSwap {
t.Fatal("stage archive cleanup hook did not run")
}
if _, err := os.Stat(filepath.Join(result.stagingRoot, stagedName)); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("staging archive = %v, want os.ErrNotExist", err)
}
gotSentinel, err := os.ReadFile(sentinelPath)
if err != nil {
t.Fatal(err)
}
if string(gotSentinel) != string(sentinel) {
t.Fatalf("outside sentinel = %q, want %q", gotSentinel, sentinel)
}
}