修复配置保存提交边界并补充失败回归测试

This commit is contained in:
xuans 2026-09-16 18:59:47 +08:00
parent 8443c712ff
commit 5f3d3a0a22
3 changed files with 273 additions and 15 deletions

View File

@ -410,21 +410,7 @@ func (c Config) Save(path string) error {
return fmt.Errorf("编码配置失败:%w", err)
}
data = append(data, '\n')
tmpPath := path + ".tmp"
if err := os.WriteFile(tmpPath, data, 0o600); err != nil {
return fmt.Errorf("写入临时配置文件失败:%w", err)
}
if err := os.Rename(tmpPath, path); err != nil {
_ = os.Remove(tmpPath)
return fmt.Errorf("替换配置文件失败:%w", err)
}
if err := os.Chmod(path, 0o600); err != nil {
return fmt.Errorf("设置配置文件权限失败:%w", err)
}
if err := applyLinuxServiceOwnership(filepath.Dir(path), path); err != nil {
return fmt.Errorf("设置配置文件归属失败:%w", err)
}
return nil
return saveConfigFile(path, data, defaultConfigSaveOperations())
}
// applyLinuxServiceOwnership 在 root 初始化配置时,将配置目录和文件交给 Scert 服务用户管理。

View File

@ -0,0 +1,75 @@
package config
import (
"fmt"
"io"
"os"
"path/filepath"
)
type configTempFile interface {
io.Writer
Name() string
Chmod(os.FileMode) error
Sync() error
Close() error
}
type configSaveOperations struct {
createTemp func(string, string) (configTempFile, error)
ownership func(string, string) error
replace func(string, string) error
}
func defaultConfigSaveOperations() configSaveOperations {
return configSaveOperations{
createTemp: func(directory, pattern string) (configTempFile, error) {
return os.CreateTemp(directory, pattern)
},
ownership: applyLinuxServiceOwnership,
replace: os.Rename,
}
}
// saveConfigFile 以同目录文件替换作为提交点,提交之后不再执行可能报错的操作。
// 目录权限和归属调整仍可能留下元数据变化;此处不保证断电后的目录项持久性。
func saveConfigFile(path string, data []byte, operations configSaveOperations) error {
directory := filepath.Dir(path)
temporary, err := operations.createTemp(directory, "."+filepath.Base(path)+"-*.tmp")
if err != nil {
return fmt.Errorf("创建临时配置文件失败:%w", err)
}
closed, committed := false, false
defer func() {
if !closed {
_ = temporary.Close()
}
if !committed {
_ = os.Remove(temporary.Name())
}
}()
if written, err := temporary.Write(data); err != nil {
return fmt.Errorf("写入临时配置文件失败:%w", err)
} else if written != len(data) {
return fmt.Errorf("写入临时配置文件失败:%w", io.ErrShortWrite)
}
if err := temporary.Chmod(0o600); err != nil {
return fmt.Errorf("设置配置文件权限失败:%w", err)
}
if err := operations.ownership(directory, temporary.Name()); err != nil {
return fmt.Errorf("设置配置文件归属失败:%w", err)
}
if err := temporary.Sync(); err != nil {
return fmt.Errorf("同步临时配置文件失败:%w", err)
}
err = temporary.Close()
closed = true
if err != nil {
return fmt.Errorf("关闭临时配置文件失败:%w", err)
}
if err := operations.replace(temporary.Name(), path); err != nil {
return fmt.Errorf("替换配置文件失败:%w", err)
}
committed = true
return nil
}

View File

@ -0,0 +1,197 @@
package config
import (
"errors"
"io"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
type failingConfigFile struct {
*os.File
stage string
err error
}
func (file failingConfigFile) Write(data []byte) (int, error) {
if file.stage == "write" {
return 0, file.err
}
if file.stage == "short_write" {
return len(data) - 1, nil
}
return file.File.Write(data)
}
func (file failingConfigFile) Chmod(mode os.FileMode) error {
if file.stage == "chmod" {
return file.err
}
return file.File.Chmod(mode)
}
func (file failingConfigFile) Sync() error {
if file.stage == "sync" {
return file.err
}
return file.File.Sync()
}
func (file failingConfigFile) Close() error {
err := file.File.Close()
if file.stage == "close" {
return file.err
}
return err
}
func TestSaveConfigFailuresPreserveDestination(t *testing.T) {
for _, existing := range []bool{false, true} {
for _, stage := range []string{"create", "write", "short_write", "chmod", "ownership", "sync", "close", "replace"} {
name := stage + "/new"
if existing {
name = stage + "/existing"
}
t.Run(name, func(t *testing.T) {
t.Parallel()
directory := t.TempDir()
path := filepath.Join(directory, "scert.json")
original := []byte("original configuration\n")
if existing {
if err := os.WriteFile(path, original, 0o600); err != nil {
t.Fatal(err)
}
}
injected := errors.New("injected save failure")
operations := defaultConfigSaveOperations()
operations.createTemp = func(dir, pattern string) (configTempFile, error) {
if stage == "create" {
return nil, injected
}
file, err := os.CreateTemp(dir, pattern)
if err != nil {
return nil, err
}
return failingConfigFile{File: file, stage: stage, err: injected}, nil
}
operations.ownership = func(string, string) error {
if stage == "ownership" {
return injected
}
return nil
}
replaceCalls := 0
operations.replace = func(from, to string) error {
replaceCalls++
if stage == "replace" {
return injected
}
return os.Rename(from, to)
}
expected := injected
if stage == "short_write" {
expected = io.ErrShortWrite
}
if err := saveConfigFile(path, []byte("new configuration\n"), operations); !errors.Is(err, expected) {
t.Fatalf("预期保存失败且保留原因,实际:%v", err)
}
if stage != "replace" && replaceCalls != 0 {
t.Fatal("准备阶段失败不得进入提交步骤")
}
data, err := os.ReadFile(path)
if existing {
if err != nil || string(data) != string(original) {
t.Fatalf("失败保存不得改变旧配置:%q,%v", data, err)
}
} else if !errors.Is(err, os.ErrNotExist) {
t.Fatalf("首次保存失败不得生成正式配置:%v", err)
}
assertNoConfigTemporaryFiles(t, directory)
})
}
}
}
func TestSaveConfigPreparesBeforeReplacement(t *testing.T) {
directory := t.TempDir()
path := filepath.Join(directory, "scert.json")
if err := os.WriteFile(path, []byte("old"), 0o600); err != nil {
t.Fatal(err)
}
// 历史固定名称临时文件不属于本次保存,必须原样保留。
if err := os.WriteFile(path+".tmp", []byte("unrelated"), 0o600); err != nil {
t.Fatal(err)
}
operations := defaultConfigSaveOperations()
prepared := false
operations.ownership = func(dir, temporary string) error {
if dir != directory || filepath.Dir(temporary) != directory || temporary == path || temporary == path+".tmp" {
t.Fatal("权限准备只能作用于本次同目录随机临时文件")
}
prepared = true
return nil
}
operations.replace = func(from, to string) error {
if !prepared || to != path {
t.Fatal("替换必须位于权限和归属准备之后")
}
old, err := os.ReadFile(path)
if err != nil || string(old) != "old" {
t.Fatal("提交前旧配置不得变化")
}
info, err := os.Stat(from)
if err != nil {
t.Fatal(err)
}
if runtime.GOOS != "windows" && info.Mode().Perm() != 0o600 {
t.Fatalf("提交前临时文件权限不正确:%v", info.Mode())
}
return os.Rename(from, to)
}
if err := saveConfigFile(path, []byte("new"), operations); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(path)
if err != nil || string(data) != "new" {
t.Fatalf("提交后正式内容不正确:%q,%v", data, err)
}
data, err = os.ReadFile(path + ".tmp")
if err != nil || string(data) != "unrelated" {
t.Fatal("不得覆盖或清理历史临时文件")
}
assertNoConfigTemporaryFiles(t, directory)
}
func TestSaveReplacesExistingConfiguration(t *testing.T) {
directory := t.TempDir()
path := filepath.Join(directory, "scert.json")
cfg := Default(directory)
if err := cfg.Save(path); err != nil {
t.Fatal(err)
}
cfg.Server.Port = 9443
if err := cfg.Save(path); err != nil {
t.Fatal(err)
}
loaded, err := Load(path)
if err != nil || loaded.Server.Port != 9443 {
t.Fatalf("覆盖保存后配置读取不正确:%v", err)
}
assertNoConfigTemporaryFiles(t, directory)
}
func assertNoConfigTemporaryFiles(t *testing.T, directory string) {
t.Helper()
entries, err := os.ReadDir(directory)
if err != nil {
t.Fatal(err)
}
for _, entry := range entries {
if strings.HasPrefix(entry.Name(), ".scert.json-") {
t.Fatalf("本次保存遗留临时文件:%s", entry.Name())
}
}
}