fix(backup): privately stage restore archive

This commit is contained in:
2026-08-18 08:13:13 +02:00
parent df00f6bfa8
commit 54698e7340
8 changed files with 178 additions and 23 deletions
+37 -16
View File
@@ -275,25 +275,24 @@ func WriteCanonicalNewPrivateFile(path string, contents []byte, mode os.FileMode
return writeCanonicalNewFile(path, contents, mode, true)
}
// CreateCanonicalNewPrivateFile exclusively creates an owner-private regular file under an
// already private parent and returns a read/write handle for streamed contents. Callers must close
// the returned handle and remove the file if their stream fails.
func CreateCanonicalNewPrivateFile(path string) (*os.File, error) {
if err := validateCanonicalNewFile(path, true); err != nil {
return nil, err
}
file, err := createCanonicalNewPrivateParentReadWriteFile(path, 0o600)
if err != nil {
return nil, ErrUnsafeFile
}
return file, nil
}
func writeCanonicalNewFile(path string, contents []byte, mode os.FileMode, requirePrivateParent bool) error {
if err := ValidateCanonicalPath(path); err != nil {
if err := validateCanonicalNewFile(path, requirePrivateParent); err != nil {
return err
}
parent := filepath.Dir(path)
if err := requireCanonicalDirectory(parent); err != nil {
return err
}
if requirePrivateParent && ValidatePrivateDirectory(parent) != nil {
return ErrUnsafeFile
}
if info, err := os.Lstat(path); err == nil {
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 || info.Mode()&os.ModeType != 0 {
return ErrUnsafeFile
}
return ErrUnsafeFile
} else if !errors.Is(err, os.ErrNotExist) {
return ErrUnsafeFile
}
var (
file *os.File
err error
@@ -323,6 +322,28 @@ func writeCanonicalNewFile(path string, contents []byte, mode os.FileMode, requi
return nil
}
func validateCanonicalNewFile(path string, requirePrivateParent bool) error {
if err := ValidateCanonicalPath(path); err != nil {
return err
}
parent := filepath.Dir(path)
if err := requireCanonicalDirectory(parent); err != nil {
return err
}
if requirePrivateParent && ValidatePrivateDirectory(parent) != nil {
return ErrUnsafeFile
}
if info, err := os.Lstat(path); err == nil {
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 || info.Mode()&os.ModeType != 0 {
return ErrUnsafeFile
}
return ErrUnsafeFile
} else if !errors.Is(err, os.ErrNotExist) {
return ErrUnsafeFile
}
return nil
}
// ReplaceCanonicalRegular durably replaces one existing private regular file without following
// symlinked path components. Platform implementations keep the temporary file in the target
// directory and use the platform's atomic replace primitive.
+52
View File
@@ -3,6 +3,7 @@ package safeio
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
@@ -97,6 +98,57 @@ func TestWriteCanonicalNewFileRejectsExistingTargets(t *testing.T) {
}
}
func TestCreateCanonicalNewPrivateFileProvidesPrivateStreamingWriter(t *testing.T) {
temporaryRoot, err := filepath.EvalSymlinks(os.TempDir())
if err != nil {
t.Fatal(err)
}
directory, err := os.MkdirTemp(temporaryRoot, "tht-safeio-private-stream-")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.RemoveAll(directory) })
if err := EnsurePrivateDirectory(directory); err != nil {
t.Fatal(err)
}
path := filepath.Join(directory, "archive.zip")
file, err := CreateCanonicalNewPrivateFile(path)
if err != nil {
t.Fatal(err)
}
if _, err := file.Write([]byte("streamed archive")); err != nil {
_ = file.Close()
t.Fatal(err)
}
if _, err := file.Seek(0, io.SeekStart); err != nil {
_ = file.Close()
t.Fatal(err)
}
streamed := make([]byte, len("streamed archive"))
if _, err := io.ReadFull(file, streamed); err != nil {
_ = file.Close()
t.Fatal(err)
}
if string(streamed) != "streamed archive" {
_ = file.Close()
t.Fatalf("streamed contents through open file = %q, want %q", streamed, "streamed archive")
}
if err := file.Close(); err != nil {
t.Fatal(err)
}
if err := ValidatePrivateRegular(path); err != nil {
t.Fatalf("ValidatePrivateRegular() = %v, want owner-private streamed file", err)
}
contents, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(contents) != "streamed archive" {
t.Fatalf("streamed contents = %q, want %q", contents, "streamed archive")
}
}
func TestPreflightPrivateDirectoryAllowsOnlyAMissingFinalComponentWithoutMutation(t *testing.T) {
temporaryRoot, err := filepath.EvalSymlinks(os.TempDir())
if err != nil {
+12 -1
View File
@@ -89,7 +89,11 @@ func ProtectPrivateRegular(path string) error {
}
func createCanonicalNewPrivateFile(path string, mode os.FileMode) (*os.File, error) {
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode)
return createCanonicalNewPrivateFileWithFlags(path, mode, os.O_WRONLY)
}
func createCanonicalNewPrivateFileWithFlags(path string, mode os.FileMode, flags int) (*os.File, error) {
file, err := os.OpenFile(path, flags|os.O_CREATE|os.O_EXCL, mode)
if err != nil {
return nil, err
}
@@ -108,6 +112,13 @@ func createCanonicalNewPrivateParentFile(path string, mode os.FileMode) (*os.Fil
return createCanonicalNewPrivateFile(path, mode)
}
func createCanonicalNewPrivateParentReadWriteFile(path string, mode os.FileMode) (*os.File, error) {
if err := ValidatePrivateDirectory(filepath.Dir(path)); err != nil {
return nil, ErrUnsafeFile
}
return createCanonicalNewPrivateFileWithFlags(path, mode, os.O_RDWR)
}
// ValidatePrivateRegular requires a canonical, single-link private regular file.
func ValidatePrivateRegular(path string) error {
if err := ValidateCanonicalPath(path); err != nil {
+8 -4
View File
@@ -96,14 +96,18 @@ func ProtectPrivateRegular(path string) error {
// createCanonicalNewPrivateFile installs the owner-only protected DACL in the CreateFile call, so
// another mutation can never observe a newly-created lock with an inherited/default DACL.
func createCanonicalNewPrivateFile(path string, mode os.FileMode) (*os.File, error) {
return createCanonicalNewFile(path, mode, false)
return createCanonicalNewFile(path, mode, false, windows.GENERIC_WRITE)
}
func createCanonicalNewPrivateParentFile(path string, mode os.FileMode) (*os.File, error) {
return createCanonicalNewFile(path, mode, true)
return createCanonicalNewFile(path, mode, true, windows.GENERIC_WRITE)
}
func createCanonicalNewFile(path string, mode os.FileMode, requirePrivateParent bool) (*os.File, error) {
func createCanonicalNewPrivateParentReadWriteFile(path string, mode os.FileMode) (*os.File, error) {
return createCanonicalNewFile(path, mode, true, windows.GENERIC_READ|windows.GENERIC_WRITE)
}
func createCanonicalNewFile(path string, mode os.FileMode, requirePrivateParent bool, access uint32) (*os.File, error) {
parents, target, err := openCanonicalWindowsParent(path)
if err != nil || len(parents.handles) == 0 || (requirePrivateParent && validateOwnerOnlyDACL(parents.handles[len(parents.handles)-1]) != nil) {
if parents != nil {
@@ -123,7 +127,7 @@ func createCanonicalNewFile(path string, mode os.FileMode, requirePrivateParent
}
handle, err := windows.CreateFile(
windows.StringToUTF16Ptr(filepath.Join(parents.directory, target)),
windows.GENERIC_WRITE,
access,
windowsRetainedHandleShareMode,
attributes,
windows.CREATE_NEW,