Implement self-update recovery (T-403)
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
appDirectoryName = "app"
|
||||
stagingDirectoryName = "staging"
|
||||
backupsDirectoryName = "backups"
|
||||
transactionFileName = "self-update-transaction.json"
|
||||
healthFileName = "self-update-health.json"
|
||||
transactionSchemaVersion = 1
|
||||
)
|
||||
|
||||
type updateLayout struct {
|
||||
root string
|
||||
target string
|
||||
staging string
|
||||
backup string
|
||||
transaction string
|
||||
health string
|
||||
requestID string
|
||||
}
|
||||
|
||||
func inspectRequest(request Request) (updateLayout, error) {
|
||||
if request.ParentPID <= 0 {
|
||||
return updateLayout{}, fmt.Errorf("%w: parent PID must be positive", ErrInvalidRequest)
|
||||
}
|
||||
if !validRequestID(request.RequestID) {
|
||||
return updateLayout{}, fmt.Errorf("%w: unsafe request ID", ErrInvalidRequest)
|
||||
}
|
||||
layout, err := inspectTarget(request.TargetDir)
|
||||
if err != nil {
|
||||
return updateLayout{}, err
|
||||
}
|
||||
if err := requireRealDirectory(layout.target, "target"); err != nil {
|
||||
return updateLayout{}, err
|
||||
}
|
||||
layout.requestID = request.RequestID
|
||||
expectedStaging := filepath.Join(layout.root, stagingDirectoryName, request.RequestID)
|
||||
if !filepath.IsAbs(request.StagingDir) || !samePath(request.StagingDir, expectedStaging) {
|
||||
return updateLayout{}, fmt.Errorf("%w: staging path does not match fixed layout", ErrUnsafeLayout)
|
||||
}
|
||||
layout.staging = expectedStaging
|
||||
layout.backup = filepath.Join(layout.root, backupsDirectoryName, request.RequestID)
|
||||
if err := requireRealDirectory(filepath.Join(layout.root, stagingDirectoryName), "staging parent"); err != nil {
|
||||
return updateLayout{}, err
|
||||
}
|
||||
if err := requireRealDirectory(layout.staging, "staging"); err != nil {
|
||||
if os.IsNotExist(unwrapPathError(err)) {
|
||||
return updateLayout{}, ErrStagingMissing
|
||||
}
|
||||
return updateLayout{}, err
|
||||
}
|
||||
return layout, nil
|
||||
}
|
||||
|
||||
func inspectTarget(targetDir string) (updateLayout, error) {
|
||||
if !filepath.IsAbs(targetDir) {
|
||||
return updateLayout{}, fmt.Errorf("%w: target path must be absolute", ErrUnsafeLayout)
|
||||
}
|
||||
target := filepath.Clean(targetDir)
|
||||
if filepath.Base(target) != appDirectoryName {
|
||||
return updateLayout{}, fmt.Errorf("%w: target must be root/app", ErrUnsafeLayout)
|
||||
}
|
||||
root := filepath.Dir(target)
|
||||
if filepath.Base(root) == "." || root == target {
|
||||
return updateLayout{}, fmt.Errorf("%w: target root is invalid", ErrUnsafeLayout)
|
||||
}
|
||||
if err := requireRealDirectory(root, "root"); err != nil {
|
||||
return updateLayout{}, err
|
||||
}
|
||||
if exists, err := realDirectoryState(target, "target"); err != nil {
|
||||
return updateLayout{}, err
|
||||
} else if !exists {
|
||||
// target can be absent while recovery restores a backed-up app.
|
||||
return updateLayout{root: root, target: target, transaction: filepath.Join(root, transactionFileName), health: filepath.Join(root, healthFileName)}, nil
|
||||
}
|
||||
return updateLayout{root: root, target: target, transaction: filepath.Join(root, transactionFileName), health: filepath.Join(root, healthFileName)}, nil
|
||||
}
|
||||
|
||||
func (layout updateLayout) validateReady(syncer DirectorySyncer) error {
|
||||
if err := requireRealDirectory(layout.target, "target"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := requireRealDirectory(layout.staging, "staging"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := verifyTreeNoLinks(layout.staging); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateRegularFile(filepath.Join(layout.staging, ProductExecutableName)); err != nil {
|
||||
return fmt.Errorf("%w: staged %s: %v", ErrUnsafeLayout, ProductExecutableName, err)
|
||||
}
|
||||
backups := filepath.Dir(layout.backup)
|
||||
if info, err := os.Lstat(backups); os.IsNotExist(err) {
|
||||
if err := os.Mkdir(backups, 0o700); err != nil {
|
||||
return fmt.Errorf("create backups directory: %w", err)
|
||||
}
|
||||
if err := syncer.SyncDirectory(layout.root); err != nil {
|
||||
return fmt.Errorf("sync root after creating backups directory: %w", err)
|
||||
}
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("%w: inspect backups parent: %v", ErrUnsafeLayout, err)
|
||||
} else if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
|
||||
return fmt.Errorf("%w: backups parent is not a real directory", ErrUnsafeLayout)
|
||||
}
|
||||
if exists, err := realDirectoryState(layout.backup, "backup"); err != nil {
|
||||
return err
|
||||
} else if exists {
|
||||
return ErrBackupExists
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (layout updateLayout) entrypoint() string {
|
||||
return filepath.Join(layout.target, ProductExecutableName)
|
||||
}
|
||||
|
||||
func requireRealDirectory(path string, description string) error {
|
||||
exists, err := realDirectoryState(path, description)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
return &os.PathError{Op: "lstat", Path: path, Err: os.ErrNotExist}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func realDirectoryState(path string, description string) (bool, error) {
|
||||
info, err := os.Lstat(path)
|
||||
if os.IsNotExist(err) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("%w: inspect %s: %v", ErrUnsafeLayout, description, err)
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
|
||||
return false, fmt.Errorf("%w: %s is not a real directory", ErrUnsafeLayout, description)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func validateRegularFile(path string) error {
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("file is not a regular non-symlink file")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validRequestID(value string) bool {
|
||||
if len(value) < 8 || len(value) > 64 || strings.HasPrefix(value, "-") || strings.HasSuffix(value, "-") {
|
||||
return false
|
||||
}
|
||||
for _, char := range value {
|
||||
if (char < 'a' || char > 'z') && (char < '0' || char > '9') && char != '-' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func samePath(left string, right string) bool {
|
||||
return filepath.Clean(left) == filepath.Clean(right)
|
||||
}
|
||||
|
||||
func unwrapPathError(err error) error {
|
||||
for {
|
||||
pathErr, ok := err.(*os.PathError)
|
||||
if !ok {
|
||||
return err
|
||||
}
|
||||
err = pathErr.Err
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user