From 5e7b57d7547906fcf29ac1fce5c55308c44c844b Mon Sep 17 00:00:00 2001 From: smail Date: Tue, 22 Sep 2026 01:09:18 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=85=8D=E7=BD=AE=E5=8E=9F?= =?UTF-8?q?=E5=AD=90=E5=86=99=E4=B8=AD=E6=96=AD=E5=90=8E=E7=9A=84=E5=90=AF?= =?UTF-8?q?=E5=8A=A8=E6=81=A2=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/utils/runtimepath/layout.go | 45 +++++++++++++ backend/utils/runtimepath/layout_test.go | 80 ++++++++++++++++++++++++ 2 files changed, 125 insertions(+) create mode 100644 backend/utils/runtimepath/layout_test.go diff --git a/backend/utils/runtimepath/layout.go b/backend/utils/runtimepath/layout.go index fee9125..000e08b 100644 --- a/backend/utils/runtimepath/layout.go +++ b/backend/utils/runtimepath/layout.go @@ -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) diff --git a/backend/utils/runtimepath/layout_test.go b/backend/utils/runtimepath/layout_test.go new file mode 100644 index 0000000..a8250f1 --- /dev/null +++ b/backend/utils/runtimepath/layout_test.go @@ -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") + } +}