From 1a8f70ca13b711b3e832dae07f92221f72d131ab Mon Sep 17 00:00:00 2001 From: eezd Date: Tue, 27 Oct 2026 15:03:57 +0800 Subject: [PATCH] fix(platform): normalize native paths for storage and backup validation --- internal/api/maintenance_uploads.go | 10 +++++- internal/api/storage.go | 6 +++- internal/api/storage_test.go | 31 +++++++++++++++++ internal/backup/backup_test.go | 3 +- internal/fileutil/filesystem_test.go | 2 +- internal/fileutil/path.go | 4 ++- internal/fileutil/space_windows.go | 11 ++++-- internal/store/backup.go | 11 +++++- internal/store/backup_test.go | 50 ++++++++++++++++++++++++++++ 9 files changed, 120 insertions(+), 8 deletions(-) create mode 100644 internal/store/backup_test.go diff --git a/internal/api/maintenance_uploads.go b/internal/api/maintenance_uploads.go index b7466b7..84449e7 100644 --- a/internal/api/maintenance_uploads.go +++ b/internal/api/maintenance_uploads.go @@ -192,13 +192,21 @@ func backupResourceError(err error) error { func (s *Server) validateResources(stage string) error { temp := &Server{FontsDir: filepath.Join(stage, "app", "fonts"), ThemesDir: filepath.Join(stage, "app", "themes"), PluginsDir: filepath.Join(stage, "app", "plugins")} for _, kind := range []string{"fonts", "themes", "plugins"} { - entries, err := os.ReadDir(filepath.Join(stage, "app", kind)) + resourceDir := filepath.Join(stage, "app", kind) + info, err := os.Stat(resourceDir) if errors.Is(err, os.ErrNotExist) { continue } if err != nil { return backupResourceError(err) } + if !info.IsDir() { + return i18n.New("w_the_resource_directory_contains_3898a5", errMaintenanceInput) + } + entries, err := os.ReadDir(resourceDir) + if err != nil { + return backupResourceError(err) + } for _, entry := range entries { if !entry.IsDir() { return i18n.New("w_the_resource_directory_contains_3898a5", errMaintenanceInput) diff --git a/internal/api/storage.go b/internal/api/storage.go index e557e37..8a702be 100644 --- a/internal/api/storage.go +++ b/internal/api/storage.go @@ -54,7 +54,11 @@ func scanStorage(ctx context.Context, dataDir, vaultDir string) (storageUsage, e if err != nil { return usage, err } - vaultRoot, err := filepath.Abs(vaultDir) + vaultRoot, err := filepath.EvalSymlinks(vaultDir) + if err != nil { + return usage, err + } + vaultRoot, err = filepath.Abs(vaultRoot) if err != nil { return usage, err } diff --git a/internal/api/storage_test.go b/internal/api/storage_test.go index fe807d5..e3e6252 100644 --- a/internal/api/storage_test.go +++ b/internal/api/storage_test.go @@ -112,3 +112,34 @@ func TestStorageIncludesOptionalAndImportedFontBytes(t *testing.T) { t.Fatal("分类与总量不一致") } } + +func TestStorageResolvesBothRootAliases(t *testing.T) { + root := t.TempDir() + data := filepath.Join(root, "data") + vault := filepath.Join(data, "vault") + if err := os.MkdirAll(vault, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(vault, "note.md"), []byte("content"), 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(data, "other.bin"), []byte("misc"), 0600); err != nil { + t.Fatal(err) + } + alias := filepath.Join(t.TempDir(), "alias") + if err := os.Symlink(root, alias); err != nil { + t.Fatal(err) + } + for _, roots := range [][2]string{ + {data, filepath.Join(alias, "data", "vault")}, + {filepath.Join(alias, "data"), vault}, + } { + usage, err := scanStorage(context.Background(), roots[0], roots[1]) + if err != nil { + t.Fatal(err) + } + if usage.TotalSize != 11 || usage.Categories[0].Size != 7 || usage.Categories[5].Size != 4 { + t.Fatalf("root aliases counted more than once: %+v", usage) + } + } +} diff --git a/internal/backup/backup_test.go b/internal/backup/backup_test.go index efabb4d..f6a331a 100644 --- a/internal/backup/backup_test.go +++ b/internal/backup/backup_test.go @@ -7,6 +7,7 @@ import ( "io" "os" "path/filepath" + "runtime" "strings" "testing" @@ -228,7 +229,7 @@ func TestLogRotationIsBounded(t *testing.T) { } for _, entry := range entries { info, _ := entry.Info() - if info.Size() > 10<<20 || info.Mode().Perm() != 0600 { + if info.Size() > 10<<20 || (runtime.GOOS != "windows" && info.Mode().Perm() != 0600) { t.Fatalf("日志未限量或权限不符: %+v", info) } } diff --git a/internal/fileutil/filesystem_test.go b/internal/fileutil/filesystem_test.go index 95455b5..cf74176 100644 --- a/internal/fileutil/filesystem_test.go +++ b/internal/fileutil/filesystem_test.go @@ -18,7 +18,7 @@ func TestAvailableBytes(t *testing.T) { } func TestLocalPath(t *testing.T) { - for _, name := range []string{"notes/中文 note.md", "vault/.rawmark/tree-icons.json"} { + for _, name := range []string{"notes/中文 note.md", "vault/.rawmark/tree-icons.json", "vault/.rawmark/trash/id/meta.json"} { if !IsLocalPath(name) { t.Fatalf("valid path rejected: %s", name) } diff --git a/internal/fileutil/path.go b/internal/fileutil/path.go index b93b9df..951d779 100644 --- a/internal/fileutil/path.go +++ b/internal/fileutil/path.go @@ -21,7 +21,9 @@ func IsLocalPath(name string) bool { } } for _, part := range strings.Split(name, "/") { - if strings.HasSuffix(part, " ") || strings.HasSuffix(part, ".") { + // Reject device basenames on every supported Windows version, including extensions. + base, _, _ := strings.Cut(part, ".") + if (base != "" && !filepath.IsLocal(strings.TrimRight(base, " "))) || strings.HasSuffix(part, " ") || strings.HasSuffix(part, ".") { return false } } diff --git a/internal/fileutil/space_windows.go b/internal/fileutil/space_windows.go index f8a2a15..2c93771 100644 --- a/internal/fileutil/space_windows.go +++ b/internal/fileutil/space_windows.go @@ -1,6 +1,10 @@ package fileutil -import "golang.org/x/sys/windows" +import ( + "os" + + "golang.org/x/sys/windows" +) // AvailableBytes returns disk space available to the current user. func AvailableBytes(dir string) (uint64, error) { @@ -10,5 +14,8 @@ func AvailableBytes(dir string) (uint64, error) { } var available uint64 err = windows.GetDiskFreeSpaceEx(name, &available, nil, nil) - return available, err + if err != nil { + return 0, &os.PathError{Op: "GetDiskFreeSpaceEx", Path: dir, Err: err} + } + return available, nil } diff --git a/internal/store/backup.go b/internal/store/backup.go index 707824e..04c9bec 100644 --- a/internal/store/backup.go +++ b/internal/store/backup.go @@ -5,6 +5,7 @@ import ( "database/sql" "encoding/json" "net/url" + "path/filepath" "rawmark/internal/i18n" "strings" ) @@ -83,7 +84,15 @@ func (s *Store) CheckBackup() error { // OpenExisting opens only existing databases; import validation must not create tables or change journal modes. func OpenExisting(path string) (*Store, error) { - db, err := sql.Open("sqlite", (&url.URL{Scheme: "file", Path: path, RawQuery: "mode=rw&_pragma=busy_timeout(5000)"}).String()) + absolute, err := filepath.Abs(path) + if err != nil { + return nil, err + } + uriPath := filepath.ToSlash(absolute) + if filepath.VolumeName(absolute) != "" && !strings.HasPrefix(uriPath, "/") { + uriPath = "/" + uriPath + } + db, err := sql.Open("sqlite", (&url.URL{Scheme: "file", Path: uriPath, RawQuery: "mode=rw&_pragma=busy_timeout(5000)"}).String()) if err != nil { return nil, err } diff --git a/internal/store/backup_test.go b/internal/store/backup_test.go new file mode 100644 index 0000000..b0d86ad --- /dev/null +++ b/internal/store/backup_test.go @@ -0,0 +1,50 @@ +package store + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" +) + +func TestOpenExistingUsesNativeEscapedPathWithoutCreating(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "数据库 # %.db") + original, err := Open(filepath.Join(root, "source.db")) + if err != nil { + t.Fatal(err) + } + if err := original.SetSetting("portable", "retained"); err != nil { + t.Fatal(err) + } + if err := original.Snapshot(context.Background(), path); err != nil { + t.Fatal(err) + } + if err := original.Close(); err != nil { + t.Fatal(err) + } + existing, err := OpenExisting(path) + if err != nil { + t.Fatal(err) + } + value, ok, err := existing.Setting("portable") + if err != nil || !ok || value != "retained" { + t.Fatalf("existing database path: %q, %v, %v", value, ok, err) + } + if err := existing.Close(); err != nil { + t.Fatal(err) + } + missingPath := filepath.Join(root, "missing.db") + missing, err := OpenExisting(missingPath) + if err != nil { + t.Fatal(err) + } + defer missing.Close() + if _, _, err := missing.Setting("portable"); err == nil { + t.Fatal("missing database accepted") + } + if _, err := os.Stat(missingPath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("missing database was created: %v", err) + } +}