303 lines
8.4 KiB
Go
303 lines
8.4 KiB
Go
package dbmigration
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"git.pcux.cn/xuans/Smail/config"
|
|
_ "modernc.org/sqlite"
|
|
)
|
|
|
|
var ErrSQLiteIntegrity = errors.New("sqlite database integrity check failed")
|
|
|
|
type SQLiteSwitch func(context.Context, config.DatabaseConfig) error
|
|
type AuthorizedSQLiteSwitch func(context.Context, uint64, config.DatabaseConfig, config.AuditActor) error
|
|
|
|
type SQLiteExecutor struct {
|
|
root string
|
|
switchConfig SQLiteSwitch
|
|
authorizedSwitch AuthorizedSQLiteSwitch
|
|
}
|
|
|
|
func NewSQLiteExecutor(root string, switchConfig SQLiteSwitch) *SQLiteExecutor {
|
|
return &SQLiteExecutor{root: root, switchConfig: switchConfig}
|
|
}
|
|
|
|
func NewAuthorizedSQLiteExecutor(root string, switchConfig AuthorizedSQLiteSwitch) *SQLiteExecutor {
|
|
return &SQLiteExecutor{root: root, authorizedSwitch: switchConfig}
|
|
}
|
|
|
|
func (e *SQLiteExecutor) Run(ctx context.Context, plan Plan, update func(Status) error) error {
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
if e == nil || update == nil {
|
|
return ErrInvalidRequest
|
|
}
|
|
if err := contextError(ctx); err != nil {
|
|
return err
|
|
}
|
|
if plan.sourceConfig.Type != config.DatabaseTypeSQLite || plan.targetConfig.Type != config.DatabaseTypeSQLite {
|
|
return ErrUnsupportedDatabase
|
|
}
|
|
if err := config.ValidateDatabaseConfig(plan.sourceConfig); err != nil {
|
|
return ErrSourceDatabase
|
|
}
|
|
if err := config.ValidateDatabaseConfig(plan.targetConfig); err != nil {
|
|
return ErrInvalidTargetName
|
|
}
|
|
if plan.sourceConfig.LocalRef == plan.targetConfig.LocalRef {
|
|
return ErrInvalidTargetName
|
|
}
|
|
|
|
sourcePath, err := e.resolveReference(plan.sourceConfig.LocalRef)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
targetPath, err := e.resolveReference(plan.targetConfig.LocalRef)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if sourcePath == targetPath {
|
|
return ErrInvalidTargetName
|
|
}
|
|
if err := checkRegularFile(sourcePath, false); err != nil {
|
|
return fmt.Errorf("open source database: %w", err)
|
|
}
|
|
if err := checkRegularFile(targetPath, true); err != nil {
|
|
return err
|
|
}
|
|
if err := update(StatusVerifying); err != nil {
|
|
return err
|
|
}
|
|
if err := verifySQLite(ctx, sourcePath); err != nil {
|
|
return err
|
|
}
|
|
if err := contextError(ctx); err != nil {
|
|
return err
|
|
}
|
|
|
|
temporary, err := e.temporaryPath(targetPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer os.Remove(temporary)
|
|
activated := false
|
|
switched := false
|
|
switchStarted := false
|
|
defer func() {
|
|
if activated && !switched && !switchStarted {
|
|
_ = os.Remove(targetPath)
|
|
}
|
|
}()
|
|
if err := vacuumSQLite(ctx, sourcePath, temporary); err != nil {
|
|
return err
|
|
}
|
|
if err := os.Chmod(temporary, 0600); err != nil {
|
|
return fmt.Errorf("set migrated database permissions: %w", err)
|
|
}
|
|
if err := verifySQLite(ctx, temporary); err != nil {
|
|
return err
|
|
}
|
|
if err := contextError(ctx); err != nil {
|
|
return err
|
|
}
|
|
if err := update(StatusSwitching); err != nil {
|
|
return err
|
|
}
|
|
if err := os.Link(temporary, targetPath); err != nil {
|
|
if os.IsExist(err) {
|
|
return ErrTargetExists
|
|
}
|
|
return fmt.Errorf("activate migrated database file: %w", err)
|
|
}
|
|
activated = true
|
|
if err := os.Remove(temporary); err != nil {
|
|
return fmt.Errorf("finalize migrated database file: %w", err)
|
|
}
|
|
if err := verifySQLite(ctx, targetPath); err != nil {
|
|
return err
|
|
}
|
|
if e.authorizedSwitch == nil && e.switchConfig == nil {
|
|
return ErrSwitchUnavailable
|
|
}
|
|
var switchErr error
|
|
switchStarted = true
|
|
if e.authorizedSwitch != nil {
|
|
switchErr = e.authorizedSwitch(ctx, plan.expectedGeneration, plan.targetConfig, plan.actor)
|
|
} else {
|
|
switchErr = e.switchConfig(ctx, plan.targetConfig)
|
|
}
|
|
if switchErr != nil {
|
|
if isKnownSwitchRejection(switchErr) {
|
|
_ = os.Remove(targetPath)
|
|
return switchErr
|
|
}
|
|
return errors.Join(ErrSwitchOutcomeUnknown, switchErr)
|
|
}
|
|
switched = true
|
|
return nil
|
|
}
|
|
|
|
func (e *SQLiteExecutor) resolveReference(reference string) (string, error) {
|
|
if e == nil || strings.TrimSpace(e.root) == "" || !validSQLiteReference(reference) {
|
|
return "", ErrInvalidTargetName
|
|
}
|
|
root, err := filepath.Abs(filepath.Clean(e.root))
|
|
if err != nil {
|
|
return "", ErrInvalidTargetName
|
|
}
|
|
configDirectory := filepath.Join(root, "data")
|
|
info, err := os.Lstat(configDirectory)
|
|
if err != nil || info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
|
|
return "", ErrInvalidTargetName
|
|
}
|
|
path := filepath.Join(configDirectory, reference)
|
|
absolute, err := filepath.Abs(filepath.Clean(path))
|
|
if err != nil || !pathWithinDirectory(configDirectory, absolute) {
|
|
return "", ErrInvalidTargetName
|
|
}
|
|
if realConfig, configErr := filepath.EvalSymlinks(configDirectory); configErr == nil {
|
|
if realTarget, targetErr := evalWithExistingAncestor(absolute); targetErr == nil && !pathWithinDirectory(realConfig, realTarget) {
|
|
return "", ErrInvalidTargetName
|
|
}
|
|
}
|
|
return absolute, nil
|
|
}
|
|
|
|
func validSQLiteReference(value string) bool {
|
|
value = filepath.ToSlash(strings.TrimSpace(value))
|
|
if value == "" || filepath.IsAbs(value) || isWindowsAbsoluteReference(value) || strings.HasPrefix(value, "config/") || strings.ContainsAny(value, `\\:`) {
|
|
return false
|
|
}
|
|
for _, part := range strings.Split(value, "/") {
|
|
if part == "" || part == "." || part == ".." || !sqliteTargetNamePattern.MatchString(part) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func isWindowsAbsoluteReference(value string) bool {
|
|
return strings.HasPrefix(value, "//") || (len(value) >= 3 && ((value[0] >= 'A' && value[0] <= 'Z') || (value[0] >= 'a' && value[0] <= 'z')) && value[1] == ':' && value[2] == '/')
|
|
}
|
|
|
|
func pathWithinDirectory(directory, target string) bool {
|
|
relative, err := filepath.Rel(directory, target)
|
|
return err == nil && !filepath.IsAbs(relative) && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator))
|
|
}
|
|
|
|
func evalWithExistingAncestor(target string) (string, error) {
|
|
current := target
|
|
parts := make([]string, 0)
|
|
for {
|
|
resolved, err := filepath.EvalSymlinks(current)
|
|
if err == nil {
|
|
for index := len(parts) - 1; index >= 0; index-- {
|
|
resolved = filepath.Join(resolved, parts[index])
|
|
}
|
|
return filepath.Abs(resolved)
|
|
}
|
|
if !os.IsNotExist(err) {
|
|
return "", err
|
|
}
|
|
parent := filepath.Dir(current)
|
|
if parent == current {
|
|
return "", err
|
|
}
|
|
parts = append(parts, filepath.Base(current))
|
|
current = parent
|
|
}
|
|
}
|
|
|
|
func (e *SQLiteExecutor) temporaryPath(target string) (string, error) {
|
|
for attempt := 0; attempt < 8; attempt++ {
|
|
id, err := randomID(".migration-")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
candidate := filepath.Join(filepath.Dir(target), id+".tmp")
|
|
if _, err := os.Lstat(candidate); os.IsNotExist(err) {
|
|
return candidate, nil
|
|
} else if err != nil {
|
|
return "", err
|
|
}
|
|
}
|
|
return "", ErrTargetExists
|
|
}
|
|
|
|
func checkRegularFile(path string, target bool) error {
|
|
info, err := os.Lstat(path)
|
|
if os.IsNotExist(err) {
|
|
if target {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("source database does not exist: %w", err)
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
|
|
if target {
|
|
return ErrTargetExists
|
|
}
|
|
return errors.New("source database is not a regular file")
|
|
}
|
|
if target {
|
|
return ErrTargetExists
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func verifySQLite(ctx context.Context, path string) error {
|
|
database, err := sql.Open("sqlite", path+"?_pragma=busy_timeout(5000)&mode=ro")
|
|
if err != nil {
|
|
return fmt.Errorf("open sqlite database: %w", err)
|
|
}
|
|
defer database.Close()
|
|
if err := database.PingContext(ctx); err != nil {
|
|
if ctx != nil && ctx.Err() != nil {
|
|
return ctx.Err()
|
|
}
|
|
return fmt.Errorf("%w: ping sqlite database: %v", ErrSQLiteIntegrity, err)
|
|
}
|
|
var result string
|
|
if err := database.QueryRowContext(ctx, "PRAGMA integrity_check").Scan(&result); err != nil {
|
|
if ctx != nil && ctx.Err() != nil {
|
|
return ctx.Err()
|
|
}
|
|
return fmt.Errorf("%w: check sqlite database integrity: %v", ErrSQLiteIntegrity, err)
|
|
}
|
|
if !strings.EqualFold(strings.TrimSpace(result), "ok") {
|
|
return fmt.Errorf("%w: %s", ErrSQLiteIntegrity, result)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func vacuumSQLite(ctx context.Context, source, target string) error {
|
|
database, err := sql.Open("sqlite", source+"?_pragma=busy_timeout(5000)")
|
|
if err != nil {
|
|
return fmt.Errorf("open sqlite source: %w", err)
|
|
}
|
|
defer database.Close()
|
|
if err := database.PingContext(ctx); err != nil {
|
|
return fmt.Errorf("ping sqlite source: %w", err)
|
|
}
|
|
if _, err := database.ExecContext(ctx, "VACUUM INTO ?", target); err != nil {
|
|
return fmt.Errorf("copy sqlite database: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func contextError(ctx context.Context) error {
|
|
if ctx == nil {
|
|
return nil
|
|
}
|
|
return ctx.Err()
|
|
}
|