From c7aeba24d7de5e1fef1a76653be9ca8160582f74 Mon Sep 17 00:00:00 2001 From: Catlaxy AI Date: Tue, 6 Oct 2026 09:23:56 +0800 Subject: [PATCH] =?UTF-8?q?fix(settings):=20=E5=8E=9F=E5=AD=90=E5=8F=91?= =?UTF-8?q?=E5=B8=83=E9=A6=96=E6=AC=A1=E5=88=9B=E5=BB=BA=E7=9A=84=E5=AD=98?= =?UTF-8?q?=E5=82=A8=E5=8A=A0=E5=AF=86=E5=AF=86=E9=92=A5=20(#688)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Claude Opus 5.5 --- backend/internal/app/settings.go | 69 ++++++-- .../app/settings_key_concurrency_test.go | 155 ++++++++++++++++++ 2 files changed, 214 insertions(+), 10 deletions(-) create mode 100644 backend/internal/app/settings_key_concurrency_test.go diff --git a/backend/internal/app/settings.go b/backend/internal/app/settings.go index 9df0ff9d4..529e1676f 100644 --- a/backend/internal/app/settings.go +++ b/backend/internal/app/settings.go @@ -15,6 +15,7 @@ import ( "path/filepath" "strconv" "strings" + "syscall" "time" "infinite-canvas/backend/internal/model" @@ -423,28 +424,76 @@ func (s *Service) settingsEncryptionKey() ([]byte, error) { if _, err := io.ReadFull(rand.Reader, key); err != nil { return nil, err } - file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) - if errors.Is(err, os.ErrExist) { + if err := publishSettingsKey(path, key, os.Link); errors.Is(err, os.ErrExist) { + return readExistingSettingsKey(path) + } else if err != nil { + return nil, err + } + return key, nil +} + +func readExistingSettingsKey(path string) ([]byte, error) { + // An older process or the exclusive-create fallback may still be writing. + for attempt := 0; attempt < 11; attempt++ { data, readErr := os.ReadFile(path) if readErr != nil { return nil, fmt.Errorf("读取存储加密密钥失败:%w", readErr) } - if len(data) != 32 { - return nil, errors.New("存储加密密钥长度无效") + if len(data) == 32 { + return data, nil + } + if attempt < 10 { + time.Sleep(5 * time.Millisecond) } - return data, nil } + return nil, errors.New("存储加密密钥长度无效") +} + +func publishSettingsKey(path string, key []byte, link func(string, string) error) error { + file, err := os.CreateTemp(filepath.Dir(path), ".settings-key-*") if err != nil { - return nil, err + return err } + defer os.Remove(file.Name()) + defer file.Close() if _, err := file.Write(key); err != nil { - _ = file.Close() - return nil, err + return err + } + if err := file.Sync(); err != nil { + return err } if err := file.Close(); err != nil { - return nil, err + return err } - return key, nil + // Link publishes complete bytes without replacing another process's key. + if err := link(file.Name(), path); err != nil { + if !errors.Is(err, syscall.ENOTSUP) && !errors.Is(err, syscall.EPERM) { + return err + } + // Some filesystems prohibit hard links. O_EXCL still prevents replacement, + // but readers may see an incomplete file and a crash may leave it invalid. + fallback, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return err + } + defer fallback.Close() + if _, err := fallback.Write(key); err != nil { + return err + } + if err := fallback.Sync(); err != nil { + return err + } + if err := fallback.Close(); err != nil { + return err + } + } + // Persist the published name, not only the inode's contents. + dir, err := os.Open(filepath.Dir(path)) + if err != nil { + return err + } + defer dir.Close() + return dir.Sync() } func (s *Service) protectTaskSecrets(value interface{}) error { diff --git a/backend/internal/app/settings_key_concurrency_test.go b/backend/internal/app/settings_key_concurrency_test.go new file mode 100644 index 000000000..41262bd09 --- /dev/null +++ b/backend/internal/app/settings_key_concurrency_test.go @@ -0,0 +1,155 @@ +package app + +import ( + "bytes" + "errors" + "os" + "path/filepath" + "strconv" + "sync" + "syscall" + "testing" + "time" +) + +func TestSettingsEncryptionKeyPublication(t *testing.T) { + path := filepath.Join(t.TempDir(), ".settings-key") + key := bytes.Repeat([]byte{5}, 32) + link := func(temp, target string) error { + data, err := os.ReadFile(temp) + if err != nil || !bytes.Equal(data, key) { + t.Fatalf("incomplete temporary key: %v", err) + } + info, err := os.Stat(temp) + if err != nil || info.Mode().Perm() != 0600 { + t.Fatalf("temporary permissions: %v", err) + } + if _, err := os.Stat(target); !os.IsNotExist(err) { + t.Fatalf("key visible before publication: %v", err) + } + return os.Link(temp, target) + } + if err := publishSettingsKey(path, key, link); err != nil { + t.Fatal(err) + } + if err := publishSettingsKey(path, bytes.Repeat([]byte{6}, 32), os.Link); !os.IsExist(err) { + t.Fatalf("publication replaced existing key: %v", err) + } + data, err := os.ReadFile(path) + if err != nil || !bytes.Equal(data, key) { + t.Fatalf("existing key changed: %v", err) + } + other := filepath.Join(t.TempDir(), ".settings-key") + if err := publishSettingsKey(other, key, func(string, string) error { return syscall.EIO }); !errors.Is(err, syscall.EIO) { + t.Fatalf("unexpected link error hidden: %v", err) + } + if _, err := os.Stat(other); !os.IsNotExist(err) { + t.Fatalf("unexpected fallback: %v", err) + } +} + +func TestSettingsEncryptionKeyLinkFallback(t *testing.T) { + for _, failure := range []error{syscall.ENOTSUP, syscall.EPERM} { + t.Run(failure.Error(), func(t *testing.T) { + path := filepath.Join(t.TempDir(), ".settings-key") + key := bytes.Repeat([]byte{3}, 32) + link := func(string, string) error { return failure } + if err := publishSettingsKey(path, key, link); err != nil { + t.Fatal(err) + } + if err := publishSettingsKey(path, bytes.Repeat([]byte{4}, 32), link); !os.IsExist(err) { + t.Fatalf("existing key: %v", err) + } + got, err := os.ReadFile(path) + if err != nil || !bytes.Equal(got, key) { + t.Fatalf("key changed: %v", err) + } + info, err := os.Stat(path) + if err != nil || info.Mode().Perm() != 0600 { + t.Fatalf("permissions: %v", err) + } + }) + } +} + +func TestSettingsEncryptionKeyConcurrentFirstUse(t *testing.T) { + dir := t.TempDir() + const workers = 64 + keys := make([][]byte, workers) + errs := make([]error, workers) + start := make(chan struct{}) + var wg sync.WaitGroup + for i := range keys { + wg.Add(1) + go func(i int) { + defer wg.Done() + <-start + keys[i], errs[i] = (&Service{dataDir: dir}).settingsEncryptionKey() + }(i) + } + close(start) + wg.Wait() + for i := range keys { + if errs[i] != nil || len(keys[i]) != 32 || !bytes.Equal(keys[i], keys[0]) { + t.Fatalf("worker %d returned inconsistent key: %v", i, errs[i]) + } + } + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + if len(entries) != 1 || entries[0].Name() != ".settings-key" { + t.Fatalf("temporary keys leaked: %v", entries) + } + info, err := entries[0].Info() + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0600 { + t.Fatalf("key permissions = %o", info.Mode().Perm()) + } +} + +func TestSettingsEncryptionKeyWaitsForLegacyWriter(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".settings-key") + f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) + if err != nil { + t.Fatal(err) + } + defer f.Close() + expected := bytes.Repeat([]byte{7}, 32) + done := make(chan error, 1) + go func() { time.Sleep(10 * time.Millisecond); _, err := f.Write(expected); done <- err }() + key, err := (&Service{dataDir: dir}).settingsEncryptionKey() + if writeErr := <-done; writeErr != nil { + t.Fatal(writeErr) + } + if err != nil || !bytes.Equal(key, expected) { + t.Fatalf("legacy writer not respected: %v", err) + } +} + +func TestSettingsEncryptionKeyNeverReplacesExisting(t *testing.T) { + for _, size := range []int{0, 17, 32, 33} { + t.Run(strconv.Itoa(size), func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".settings-key") + expected := bytes.Repeat([]byte{9}, size) + if err := os.WriteFile(path, expected, 0600); err != nil { + t.Fatal(err) + } + key, err := (&Service{dataDir: dir}).settingsEncryptionKey() + if (err == nil) != (size == 32) { + t.Fatalf("length %d: %v", size, err) + } + actual, readErr := os.ReadFile(path) + if readErr != nil || !bytes.Equal(actual, expected) { + t.Fatal("existing key changed", readErr) + } + if size == 32 && !bytes.Equal(key, expected) { + t.Fatal("existing key not returned") + } + }) + } +}