289 lines
7.9 KiB
Go
289 lines
7.9 KiB
Go
package plugins
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
)
|
|
|
|
type renameFunc func(source, destination string) error
|
|
|
|
type fileChange struct {
|
|
filePath string
|
|
label string
|
|
original []byte
|
|
originalMode os.FileMode
|
|
existed bool
|
|
next []byte
|
|
mode os.FileMode
|
|
remove bool
|
|
}
|
|
|
|
type stagedChange struct {
|
|
change fileChange
|
|
temporaryPath string
|
|
rollbackPath string
|
|
committed bool
|
|
}
|
|
|
|
func readOptionalFile(path, label string) ([]byte, bool, error) {
|
|
raw, err := os.ReadFile(path) // #nosec G304 -- callers constrain paths to managed user configuration files.
|
|
if err == nil {
|
|
return raw, true, nil
|
|
}
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return nil, false, nil
|
|
}
|
|
return nil, false, fmt.Errorf("read %s at %s: %w", label, path, err)
|
|
}
|
|
|
|
func prepareFileChange(filePath, label string, next []byte, mode os.FileMode) (*fileChange, error) {
|
|
original, existed, err := readOptionalFile(filePath, label)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return prepareSourceChange(filePath, label, original, existed, next, mode, false)
|
|
}
|
|
|
|
func prepareSourceChange(
|
|
filePath, label string,
|
|
original []byte,
|
|
existed bool,
|
|
next []byte,
|
|
mode os.FileMode,
|
|
remove bool,
|
|
) (*fileChange, error) {
|
|
if !existed {
|
|
original = nil
|
|
}
|
|
if existed && !remove && bytes.Equal(original, next) {
|
|
return nil, nil
|
|
}
|
|
if !existed && (remove || next == nil) {
|
|
return nil, nil
|
|
}
|
|
originalMode := mode
|
|
if existed {
|
|
info, err := os.Stat(filePath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read file mode for %s at %s: %w", label, filePath, err)
|
|
}
|
|
originalMode = info.Mode().Perm()
|
|
}
|
|
return &fileChange{
|
|
filePath: filePath, label: label, original: append([]byte(nil), original...),
|
|
originalMode: originalMode, existed: existed, next: append([]byte(nil), next...),
|
|
mode: mode, remove: remove,
|
|
}, nil
|
|
}
|
|
|
|
func writeChanges(changes []*fileChange, label string, rename renameFunc) error {
|
|
filtered := make([]fileChange, 0, len(changes))
|
|
targets := map[string]bool{}
|
|
for _, change := range changes {
|
|
if change == nil || (change.existed && !change.remove && bytes.Equal(change.original, change.next)) {
|
|
continue
|
|
}
|
|
target := filepath.Clean(change.filePath)
|
|
if targets[target] {
|
|
return fmt.Errorf("%s contains duplicate file target %s", label, change.filePath)
|
|
}
|
|
targets[target] = true
|
|
filtered = append(filtered, *change)
|
|
}
|
|
if len(filtered) == 0 {
|
|
return nil
|
|
}
|
|
if rename == nil {
|
|
rename = os.Rename
|
|
}
|
|
staged := make([]stagedChange, 0, len(filtered))
|
|
for _, change := range filtered {
|
|
item, err := stageChange(change)
|
|
if err != nil {
|
|
cleanupErrors := cleanupStaging(staged)
|
|
return joinTransactionFailure(fmt.Errorf("%s: %w", label, err), cleanupErrors, "cleanup failed")
|
|
}
|
|
staged = append(staged, item)
|
|
}
|
|
for index := range staged {
|
|
if err := commitChange(&staged[index], rename); err != nil {
|
|
recoveryErrors := rollbackChanges(staged, rename)
|
|
recoveryErrors = append(recoveryErrors, cleanupStaging(staged)...)
|
|
return joinTransactionFailure(fmt.Errorf("%s: %w", label, err), recoveryErrors, "recovery failed")
|
|
}
|
|
}
|
|
cleanupErrors := cleanupStaging(staged)
|
|
if len(cleanupErrors) > 0 {
|
|
return joinTransactionFailure(fmt.Errorf("%s cleanup failed", label), cleanupErrors, "cleanup failed")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func stageChange(change fileChange) (stagedChange, error) {
|
|
if err := assertFileUnchanged(change); err != nil {
|
|
return stagedChange{}, err
|
|
}
|
|
directory := filepath.Dir(change.filePath)
|
|
if err := os.MkdirAll(directory, 0700); err != nil {
|
|
return stagedChange{}, fmt.Errorf("create directory for %s at %s: %w", change.label, directory, err)
|
|
}
|
|
staged := stagedChange{change: change}
|
|
var err error
|
|
if change.remove {
|
|
staged.rollbackPath, err = reserveStagingPath(directory, filepath.Base(change.filePath), ".rollback")
|
|
} else {
|
|
staged.temporaryPath, err = writeStagedFile(
|
|
directory, filepath.Base(change.filePath), ".tmp", change.next, change.mode,
|
|
)
|
|
if err == nil && change.existed {
|
|
staged.rollbackPath, err = writeStagedFile(
|
|
directory, filepath.Base(change.filePath), ".rollback", change.original, change.originalMode,
|
|
)
|
|
}
|
|
}
|
|
if err != nil {
|
|
cleanupErrors := cleanupStaging([]stagedChange{staged})
|
|
return stagedChange{}, joinTransactionFailure(
|
|
fmt.Errorf("stage %s: %w", change.label, err), cleanupErrors, "cleanup failed",
|
|
)
|
|
}
|
|
return staged, nil
|
|
}
|
|
|
|
func writeStagedFile(
|
|
directory, basename, suffix string,
|
|
content []byte,
|
|
mode os.FileMode,
|
|
) (string, error) {
|
|
file, err := os.CreateTemp(directory, "."+basename+".*"+suffix)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
path := file.Name()
|
|
failed := func(cause error) (string, error) {
|
|
return "", errors.Join(cause, file.Close(), removeOptionalFile(path))
|
|
}
|
|
if err := file.Chmod(mode); err != nil {
|
|
return failed(err)
|
|
}
|
|
written, err := file.Write(content)
|
|
if err != nil {
|
|
return failed(err)
|
|
}
|
|
if written != len(content) {
|
|
return failed(io.ErrShortWrite)
|
|
}
|
|
if err := file.Sync(); err != nil {
|
|
return failed(err)
|
|
}
|
|
if err := file.Close(); err != nil {
|
|
return "", errors.Join(err, removeOptionalFile(path))
|
|
}
|
|
return path, nil
|
|
}
|
|
|
|
func reserveStagingPath(directory, basename, suffix string) (string, error) {
|
|
file, err := os.CreateTemp(directory, "."+basename+".*"+suffix)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
path := file.Name()
|
|
if err := file.Close(); err != nil {
|
|
return "", errors.Join(err, removeOptionalFile(path))
|
|
}
|
|
if err := os.Remove(path); err != nil {
|
|
return "", err
|
|
}
|
|
return path, nil
|
|
}
|
|
|
|
func assertFileUnchanged(change fileChange) error {
|
|
current, existed, err := readOptionalFile(change.filePath, change.label)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if existed != change.existed || !bytes.Equal(current, change.original) {
|
|
return fmt.Errorf("%s changed during installation: %s", change.label, change.filePath)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func commitChange(staged *stagedChange, rename renameFunc) error {
|
|
if err := assertFileUnchanged(staged.change); err != nil {
|
|
return err
|
|
}
|
|
if staged.change.remove {
|
|
if staged.rollbackPath == "" {
|
|
return fmt.Errorf("missing rollback path for %s", staged.change.label)
|
|
}
|
|
if err := rename(staged.change.filePath, staged.rollbackPath); err != nil {
|
|
return fmt.Errorf("remove %s at %s: %w", staged.change.label, staged.change.filePath, err)
|
|
}
|
|
} else {
|
|
if staged.temporaryPath == "" {
|
|
return fmt.Errorf("missing staged file for %s", staged.change.label)
|
|
}
|
|
if err := rename(staged.temporaryPath, staged.change.filePath); err != nil {
|
|
return fmt.Errorf("commit %s at %s: %w", staged.change.label, staged.change.filePath, err)
|
|
}
|
|
}
|
|
staged.committed = true
|
|
return nil
|
|
}
|
|
|
|
func rollbackChanges(staged []stagedChange, rename renameFunc) []error {
|
|
failures := make([]error, 0)
|
|
for index := len(staged) - 1; index >= 0; index-- {
|
|
item := &staged[index]
|
|
if !item.committed {
|
|
continue
|
|
}
|
|
var err error
|
|
switch {
|
|
case item.change.remove:
|
|
err = rename(item.rollbackPath, item.change.filePath)
|
|
case item.change.existed:
|
|
err = rename(item.rollbackPath, item.change.filePath)
|
|
default:
|
|
err = removeOptionalFile(item.change.filePath)
|
|
}
|
|
if err != nil {
|
|
failures = append(failures, fmt.Errorf("restore %s at %s: %w", item.change.label, item.change.filePath, err))
|
|
}
|
|
}
|
|
return failures
|
|
}
|
|
|
|
func cleanupStaging(staged []stagedChange) []error {
|
|
failures := make([]error, 0)
|
|
for _, item := range staged {
|
|
for _, path := range []string{item.temporaryPath, item.rollbackPath} {
|
|
if err := removeOptionalFile(path); err != nil {
|
|
failures = append(failures, err)
|
|
}
|
|
}
|
|
}
|
|
return failures
|
|
}
|
|
|
|
func removeOptionalFile(path string) error {
|
|
if path == "" {
|
|
return nil
|
|
}
|
|
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
|
|
return fmt.Errorf("remove staged file at %s: %w", path, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func joinTransactionFailure(cause error, related []error, label string) error {
|
|
if len(related) == 0 {
|
|
return cause
|
|
}
|
|
return errors.Join(cause, fmt.Errorf("%s: %w", label, errors.Join(related...)))
|
|
}
|