This commit is contained in:
parent
079ebee495
commit
5e7b57d754
@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@ -60,6 +61,8 @@ var obsoleteFiles = map[string]bool{
|
||||
"config.dev.json": true, "config_mysql.json": true, "config.json.legacy": true,
|
||||
}
|
||||
|
||||
var atomicTemporaryFilePattern = regexp.MustCompile(`^\.(?:config\.json|smtp\.json|email\.json|ssl\.json|plugins\.json)-[0-9a-f]{32}$`)
|
||||
|
||||
type Move struct {
|
||||
Source string
|
||||
Target string
|
||||
@ -90,6 +93,16 @@ func Plan(root string) ([]Move, error) {
|
||||
var moves []Move
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
if atomicTemporaryFilePattern.MatchString(name) {
|
||||
info, err := os.Lstat(filepath.Join(configDirectory, name))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
|
||||
return nil, errors.New("配置临时文件不能是目录")
|
||||
}
|
||||
continue
|
||||
}
|
||||
if configurationFiles[name] && !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
@ -153,6 +166,9 @@ func CheckReady(root string) error {
|
||||
if err := CheckKeyLayout(root); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := cleanAtomicTemporaryFiles(root); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := os.Lstat(filepath.Join(root, "data", migrationLock)); err == nil || !os.IsNotExist(err) {
|
||||
return errors.New("运行目录迁移尚未结束,请保留现场并核对迁移锁")
|
||||
}
|
||||
@ -219,6 +235,35 @@ func CheckReady(root string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// cleanAtomicTemporaryFiles 丢弃未完成的原子写入临时文件,不能触碰未知文件。
|
||||
func cleanAtomicTemporaryFiles(root string) error {
|
||||
configDirectory := filepath.Join(root, "config")
|
||||
entries, err := os.ReadDir(configDirectory)
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if !atomicTemporaryFilePattern.MatchString(entry.Name()) {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(configDirectory, entry.Name())
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
|
||||
return errors.New("配置原子临时文件无效")
|
||||
}
|
||||
if err := os.Remove(path); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Migrate 必须停服调用;同卷重命名保留文件内容,失败逆序回滚,重试保持幂等。
|
||||
func Migrate(root string) ([]Move, error) {
|
||||
return migrate(root, os.Rename)
|
||||
|
||||
80
backend/utils/runtimepath/layout_test.go
Normal file
80
backend/utils/runtimepath/layout_test.go
Normal file
@ -0,0 +1,80 @@
|
||||
package runtimepath
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCheckReadyRemovesInterruptedAtomicConfigurationTemporaryFiles(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.Mkdir(filepath.Join(root, "config"), 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
temporary := filepath.Join(root, "config", ".config.json-0123456789abcdef0123456789abcdef")
|
||||
if err := os.WriteFile(temporary, []byte("incomplete"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := CheckReady(root); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(temporary); !os.IsNotExist(err) {
|
||||
t.Fatalf("temporary file still exists: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckReadyRejectsAtomicTemporaryDirectory(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
configDirectory := filepath.Join(root, "config")
|
||||
if err := os.Mkdir(configDirectory, 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(configDirectory, ".config.json-0123456789abcdef0123456789abcdef")
|
||||
if err := os.Mkdir(path, 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := CheckReady(root); err == nil {
|
||||
t.Fatal("expected invalid temporary directory to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckReadyRejectsAtomicTemporarySymlink(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("当前 Windows 测试环境通常没有创建符号链接的权限")
|
||||
}
|
||||
root := t.TempDir()
|
||||
configDirectory := filepath.Join(root, "config")
|
||||
if err := os.Mkdir(configDirectory, 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(configDirectory, ".config.json-0123456789abcdef0123456789abcdef")
|
||||
if err := os.Symlink(filepath.Join(configDirectory, "missing"), path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := CheckReady(root); err == nil {
|
||||
t.Fatal("expected invalid temporary symlink to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanIgnoresKnownAtomicTemporaryFilesButRejectsUnknownFiles(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
configDirectory := filepath.Join(root, "config")
|
||||
if err := os.Mkdir(configDirectory, 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
known := filepath.Join(configDirectory, ".smtp.json-0123456789abcdef0123456789abcdef")
|
||||
if err := os.WriteFile(known, []byte("incomplete"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if moves, err := Plan(root); err != nil || len(moves) != 0 {
|
||||
t.Fatalf("known temporary file should be ignored, moves=%v err=%v", moves, err)
|
||||
}
|
||||
unknown := filepath.Join(configDirectory, ".not-an-atomic-file")
|
||||
if err := os.WriteFile(unknown, []byte("unexpected"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Plan(root); err == nil {
|
||||
t.Fatal("unknown file should be rejected")
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user