diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index a8a8baa..611997f 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -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 服务用户管理。 diff --git a/backend/internal/config/save.go b/backend/internal/config/save.go new file mode 100644 index 0000000..926c12a --- /dev/null +++ b/backend/internal/config/save.go @@ -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 +} diff --git a/backend/internal/config/save_test.go b/backend/internal/config/save_test.go new file mode 100644 index 0000000..af17b10 --- /dev/null +++ b/backend/internal/config/save_test.go @@ -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()) + } + } +}