From e239fff25384e5c8ab034e6e6e2943e3e3964409 Mon Sep 17 00:00:00 2001 From: eezd Date: Tue, 27 Oct 2026 14:47:25 +0800 Subject: [PATCH] fix(platform): support Windows storage and native filesystem boundaries --- cmd/rawmark/main.go | 14 ++++++-- go.mod | 2 +- internal/api/export_notes_test.go | 2 ++ internal/api/maintenance_uploads.go | 7 ++-- internal/api/plugins.go | 5 +-- internal/api/plugins_windows_test.go | 38 +++++++++++++++++++++ internal/backup/archive.go | 9 ++--- internal/backup/path_windows_test.go | 32 ++++++++++++++++++ internal/backup/record_unix.go | 20 +++++++++++ internal/backup/record_windows.go | 16 +++++++++ internal/backup/restore.go | 10 +----- internal/fileutil/filesystem_test.go | 36 ++++++++++++++++++++ internal/fileutil/path.go | 30 +++++++++++++++++ internal/fileutil/space_unix.go | 14 ++++++++ internal/fileutil/space_windows.go | 14 ++++++++ internal/testutil/filesystem_unix.go | 45 +++++++++++++++++++++++++ internal/testutil/filesystem_windows.go | 41 ++++++++++++++++++++++ internal/vault/export_notes_test.go | 2 ++ internal/vault/path_windows_test.go | 15 +++++++++ internal/vault/trash_test.go | 18 +++++----- internal/vault/tree_icons_test.go | 10 ++---- internal/vault/vault.go | 3 +- internal/vault/vault_test.go | 9 +++-- internal/watch/watch_test.go | 14 +++----- 24 files changed, 355 insertions(+), 51 deletions(-) create mode 100644 internal/api/plugins_windows_test.go create mode 100644 internal/backup/path_windows_test.go create mode 100644 internal/backup/record_unix.go create mode 100644 internal/backup/record_windows.go create mode 100644 internal/fileutil/filesystem_test.go create mode 100644 internal/fileutil/path.go create mode 100644 internal/fileutil/space_unix.go create mode 100644 internal/fileutil/space_windows.go create mode 100644 internal/testutil/filesystem_unix.go create mode 100644 internal/testutil/filesystem_windows.go create mode 100644 internal/vault/path_windows_test.go diff --git a/cmd/rawmark/main.go b/cmd/rawmark/main.go index d565e71..516a4b6 100644 --- a/cmd/rawmark/main.go +++ b/cmd/rawmark/main.go @@ -17,6 +17,7 @@ import ( "os" "os/signal" "path/filepath" + "runtime" "sync" "syscall" "time" @@ -77,8 +78,14 @@ func run(addr string) error { if err = os.MkdirAll(tempDir, 0700); err != nil { return err } - if err = os.Setenv("TMPDIR", tempDir); err != nil { - return err + tempKeys := []string{"TMPDIR"} + if runtime.GOOS == "windows" { + tempKeys = []string{"TMP", "TEMP"} + } + for _, key := range tempKeys { + if err = os.Setenv(key, tempDir); err != nil { + return err + } } logs, err := backup.OpenLog(filepath.Join(dataDir, "logs")) if err != nil { @@ -221,6 +228,9 @@ func serve(parent context.Context, addr, dataDir, vaultDir, version string, font // permissionHint adds guidance for permission errors: Docker bind mounts are often not writable by non-root container users. func permissionHint(err error) error { if errors.Is(err, fs.ErrPermission) { + if runtime.GOOS == "windows" { + return fmt.Errorf("%w (grant this user write access to RAWMARK_DATA and RAWMARK_VAULT)", err) + } return fmt.Errorf("%w(当前 UID %d 对数据目录没有写权限:请用 --user 指定宿主机目录所有者的 UID:GID,或执行 chown 修改目录所有者)", err, os.Getuid()) } return err diff --git a/go.mod b/go.mod index b16ff15..ec289f6 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/yuin/goldmark/v2 v2.1.8 go.yaml.in/yaml/v3 v3.0.5 golang.org/x/crypto v0.58.0 + golang.org/x/sys v0.49.0 modernc.org/sqlite v1.60.1 ) @@ -18,7 +19,6 @@ require ( github.com/mattn/go-isatty v0.0.24 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - golang.org/x/sys v0.49.0 // indirect modernc.org/libc v1.77.1 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.12.1 // indirect diff --git a/internal/api/export_notes_test.go b/internal/api/export_notes_test.go index fac0792..85774dc 100644 --- a/internal/api/export_notes_test.go +++ b/internal/api/export_notes_test.go @@ -78,6 +78,8 @@ func TestPortableNotesExcludeApplicationData(t *testing.T) { } // As in the real process, export temporary files live inside the application data directory. t.Setenv("TMPDIR", filepath.Join(data, "temp")) + t.Setenv("TMP", filepath.Join(data, "temp")) + t.Setenv("TEMP", filepath.Join(data, "temp")) res = f.do(f.admin, "POST", "/api/v1/maintenance/notes/export", "", nil) defer res.Body.Close() assertOK(t, res) diff --git a/internal/api/maintenance_uploads.go b/internal/api/maintenance_uploads.go index e2c4e23..b7466b7 100644 --- a/internal/api/maintenance_uploads.go +++ b/internal/api/maintenance_uploads.go @@ -9,6 +9,7 @@ import ( "net/http" "os" "path/filepath" + "rawmark/internal/fileutil" "rawmark/internal/i18n" "regexp" "strconv" @@ -65,11 +66,11 @@ func saveUpload(dir string, meta maintenanceUpload) error { return os.WriteFile(filepath.Join(dir, "upload.json"), data, 0600) } func enoughSpace(dir string, size int64) error { - var stat syscall.Statfs_t - if err := syscall.Statfs(dir, &stat); err != nil { + available, err := fileutil.AvailableBytes(dir) + if err != nil { return err } - if size < 0 || uint64(size)+(32<<20) > stat.Bavail*uint64(stat.Bsize) { + if size < 0 || uint64(size)+(32<<20) > available { return i18n.New("not_enough_disk_space_w_a39b20", syscall.ENOSPC) } return nil diff --git a/internal/api/plugins.go b/internal/api/plugins.go index c1f692a..3d29c4a 100644 --- a/internal/api/plugins.go +++ b/internal/api/plugins.go @@ -14,6 +14,7 @@ import ( "os" "path" "path/filepath" + "rawmark/internal/fileutil" "rawmark/internal/i18n" "regexp" "strings" @@ -42,7 +43,7 @@ type pluginInfo struct { } func validPluginID(id string) bool { - return pluginIDPattern.MatchString(id) && !strings.HasPrefix(id, "rawmark-") + return pluginIDPattern.MatchString(id) && fileutil.IsLocalPath(id) && !strings.HasPrefix(id, "rawmark-") } func parsePluginManifest(data []byte) (pluginManifest, error) { @@ -181,7 +182,7 @@ func unpackPlugin(data []byte, destination string) (pluginInfo, error) { var total int64 for _, file := range archive.File { name := strings.TrimSuffix(file.Name, "/") - if !fs.ValidPath(name) || strings.ContainsAny(name, "\\:\x00") || strings.HasPrefix(name, ".") || name != path.Clean(name) || seen[name] || (!file.FileInfo().IsDir() && !file.Mode().IsRegular()) { + if !fs.ValidPath(name) || !fileutil.IsLocalPath(name) || strings.ContainsAny(name, "\\:\x00") || strings.HasPrefix(name, ".") || name != path.Clean(name) || seen[name] || (!file.FileInfo().IsDir() && !file.Mode().IsRegular()) { return info, i18n.New("plugin_contains_invalid_paths_duplicate_6a1e05") } seen[name] = true diff --git a/internal/api/plugins_windows_test.go b/internal/api/plugins_windows_test.go new file mode 100644 index 0000000..0d22458 --- /dev/null +++ b/internal/api/plugins_windows_test.go @@ -0,0 +1,38 @@ +package api + +import ( + "archive/zip" + "bytes" + "testing" +) + +func TestWindowsPluginPaths(t *testing.T) { + if validPluginID("con") { + t.Fatal("reserved Windows plugin ID accepted") + } + for _, name := range []string{"main.js.", "dir /module.js", "NUL.js", "module.js:stream"} { + t.Run(name, func(t *testing.T) { + var data bytes.Buffer + archive := zip.NewWriter(&data) + for _, entry := range []struct{ name, content string }{ + {"manifest.json", `{"id":"portable","name":"Portable","version":"1","apiVersion":1}`}, + {"main.js", "export default {}"}, + {name, "must not overwrite"}, + } { + file, err := archive.Create(entry.name) + if err != nil { + t.Fatal(err) + } + if _, err := file.Write([]byte(entry.content)); err != nil { + t.Fatal(err) + } + } + if err := archive.Close(); err != nil { + t.Fatal(err) + } + if _, err := unpackPlugin(data.Bytes(), t.TempDir()); err == nil { + t.Fatal("unsafe Windows archive path accepted") + } + }) + } +} diff --git a/internal/backup/archive.go b/internal/backup/archive.go index 822a822..250f359 100644 --- a/internal/backup/archive.go +++ b/internal/backup/archive.go @@ -13,6 +13,7 @@ import ( "os" "path" "path/filepath" + "rawmark/internal/fileutil" "rawmark/internal/i18n" "strings" "syscall" @@ -42,7 +43,7 @@ type Manifest struct { type Source struct{ Name, Path string } func validPath(name string) bool { - return name != "" && path.Clean(name) == name && name != "." && !strings.HasPrefix(name, "/") && name != ".." && !strings.HasPrefix(name, "../") && !strings.ContainsAny(name, "\\\x00") + return fileutil.IsLocalPath(name) && path.Clean(name) == name && name != "." && !strings.ContainsAny(name, "\\\x00") } func validEntry(kind, name string) bool { if !validPath(name) { @@ -237,11 +238,11 @@ func Extract(ctx context.Context, archive, dest string) (Manifest, error) { } expanded += e.Size } - var disk syscall.Statfs_t - if err := syscall.Statfs(dest, &disk); err != nil { + available, err := fileutil.AvailableBytes(dest) + if err != nil { return manifest, err } - if uint64(expanded)+(32<<20) > disk.Bavail*uint64(disk.Bsize) { + if uint64(expanded)+(32<<20) > available { return manifest, i18n.New("not_enough_disk_space_w_a39b20", syscall.ENOSPC) } seen := map[string]bool{} diff --git a/internal/backup/path_windows_test.go b/internal/backup/path_windows_test.go new file mode 100644 index 0000000..4189f8a --- /dev/null +++ b/internal/backup/path_windows_test.go @@ -0,0 +1,32 @@ +package backup + +import ( + "archive/zip" + "context" + "errors" + "os" + "path/filepath" + "testing" +) + +func TestRejectWindowsArchivePaths(t *testing.T) { + for _, name := range []string{"C:escape", "vault/note.md:stream", "vault/CON", "vault/dir./note.md"} { + t.Run(name, func(t *testing.T) { + file := filepath.Join(t.TempDir(), "invalid.zip") + f, err := os.Create(file) + if err != nil { + t.Fatal(err) + } + z := zip.NewWriter(f) + if _, err := z.Create(name); err != nil { + t.Fatal(err) + } + if err := errors.Join(z.Close(), f.Close()); err != nil { + t.Fatal(err) + } + if _, err := Extract(context.Background(), file, t.TempDir()); !errors.Is(err, ErrInvalidArchive) { + t.Fatalf("unsafe archive path accepted: %v", err) + } + }) + } +} diff --git a/internal/backup/record_unix.go b/internal/backup/record_unix.go new file mode 100644 index 0000000..f741965 --- /dev/null +++ b/internal/backup/record_unix.go @@ -0,0 +1,20 @@ +//go:build !windows + +package backup + +import ( + "errors" + "os" + "path/filepath" +) + +func publishRecord(source, dest string) error { + if err := os.Rename(source, dest); err != nil { + return err + } + dir, err := os.Open(filepath.Dir(dest)) + if err != nil { + return err + } + return errors.Join(dir.Sync(), dir.Close()) +} diff --git a/internal/backup/record_windows.go b/internal/backup/record_windows.go new file mode 100644 index 0000000..322974b --- /dev/null +++ b/internal/backup/record_windows.go @@ -0,0 +1,16 @@ +package backup + +import "golang.org/x/sys/windows" + +func publishRecord(source, dest string) error { + from, err := windows.UTF16PtrFromString(source) + if err != nil { + return err + } + to, err := windows.UTF16PtrFromString(dest) + if err != nil { + return err + } + // The file was flushed before closing; request a completed on-disk replacement. + return windows.MoveFileEx(from, to, windows.MOVEFILE_REPLACE_EXISTING|windows.MOVEFILE_WRITE_THROUGH) +} diff --git a/internal/backup/restore.go b/internal/backup/restore.go index e1964c0..cd39c9e 100644 --- a/internal/backup/restore.go +++ b/internal/backup/restore.go @@ -51,15 +51,7 @@ func WriteRecord(data string, record RestoreRecord) error { if err != nil { return err } - if err = os.Rename(f.Name(), dest); err != nil { - return err - } - dir, err := os.Open(filepath.Dir(dest)) - if err != nil { - return err - } - defer dir.Close() - return dir.Sync() + return publishRecord(f.Name(), dest) } func Pending(data string) (bool, error) { _, err := os.Stat(recordPath(data)) diff --git a/internal/fileutil/filesystem_test.go b/internal/fileutil/filesystem_test.go new file mode 100644 index 0000000..95455b5 --- /dev/null +++ b/internal/fileutil/filesystem_test.go @@ -0,0 +1,36 @@ +package fileutil + +import ( + "path/filepath" + "runtime" + "testing" +) + +func TestAvailableBytes(t *testing.T) { + root := t.TempDir() + available, err := AvailableBytes(root) + if err != nil || available == 0 { + t.Fatalf("available space: %d, %v", available, err) + } + if _, err := AvailableBytes(filepath.Join(root, "missing")); err == nil { + t.Fatal("missing directory accepted") + } +} + +func TestLocalPath(t *testing.T) { + for _, name := range []string{"notes/中文 note.md", "vault/.rawmark/tree-icons.json"} { + if !IsLocalPath(name) { + t.Fatalf("valid path rejected: %s", name) + } + } + for _, name := range []string{"", "../outside", "/absolute"} { + if IsLocalPath(name) { + t.Fatalf("nonlocal path accepted: %s", name) + } + } + for _, name := range []string{"C:note.md", "note.md:stream", "CON", "a/NUL.txt", "a/note. ", "a/note.", "a?/note.md", "a/line\n.md"} { + if got := IsLocalPath(name); got != (runtime.GOOS != "windows") { + t.Fatalf("native path %q: %v", name, got) + } + } +} diff --git a/internal/fileutil/path.go b/internal/fileutil/path.go new file mode 100644 index 0000000..b93b9df --- /dev/null +++ b/internal/fileutil/path.go @@ -0,0 +1,30 @@ +package fileutil + +import ( + "path/filepath" + "runtime" + "strings" +) + +// IsLocalPath also rejects Windows aliases and invalid native filename characters. +func IsLocalPath(name string) bool { + if !filepath.IsLocal(name) { + return false + } + if runtime.GOOS == "windows" { + if strings.ContainsAny(name, `<>:"\|?*`) { + return false + } + for _, c := range name { + if c < 32 { + return false + } + } + for _, part := range strings.Split(name, "/") { + if strings.HasSuffix(part, " ") || strings.HasSuffix(part, ".") { + return false + } + } + } + return true +} diff --git a/internal/fileutil/space_unix.go b/internal/fileutil/space_unix.go new file mode 100644 index 0000000..7b1f3b3 --- /dev/null +++ b/internal/fileutil/space_unix.go @@ -0,0 +1,14 @@ +//go:build !windows + +package fileutil + +import "syscall" + +// AvailableBytes returns disk space available to the current user. +func AvailableBytes(dir string) (uint64, error) { + var stat syscall.Statfs_t + if err := syscall.Statfs(dir, &stat); err != nil { + return 0, err + } + return stat.Bavail * uint64(stat.Bsize), nil +} diff --git a/internal/fileutil/space_windows.go b/internal/fileutil/space_windows.go new file mode 100644 index 0000000..f8a2a15 --- /dev/null +++ b/internal/fileutil/space_windows.go @@ -0,0 +1,14 @@ +package fileutil + +import "golang.org/x/sys/windows" + +// AvailableBytes returns disk space available to the current user. +func AvailableBytes(dir string) (uint64, error) { + name, err := windows.UTF16PtrFromString(dir) + if err != nil { + return 0, err + } + var available uint64 + err = windows.GetDiskFreeSpaceEx(name, &available, nil, nil) + return available, err +} diff --git a/internal/testutil/filesystem_unix.go b/internal/testutil/filesystem_unix.go new file mode 100644 index 0000000..f293655 --- /dev/null +++ b/internal/testutil/filesystem_unix.go @@ -0,0 +1,45 @@ +//go:build !windows + +// Package testutil provides real filesystem failure conditions for platform tests. +package testutil + +import ( + "os" + "path/filepath" + "sync" + "testing" +) + +func denyMode(t *testing.T, name string, mode os.FileMode) func() { + t.Helper() + if os.Geteuid() == 0 { + t.Skip("Permission tests require an unprivileged user") + } + info, err := os.Stat(name) + if err != nil { + t.Fatal(err) + } + if err := os.Chmod(name, mode); err != nil { + t.Fatal(err) + } + release := sync.OnceFunc(func() { + if err := os.Chmod(name, info.Mode().Perm()); err != nil { + t.Error(err) + } + }) + t.Cleanup(release) + return release +} + +func DenyRead(t *testing.T, name string) func() { + t.Helper() + return denyMode(t, name, 0) +} +func DenyReplace(t *testing.T, name string) func() { + t.Helper() + return denyMode(t, filepath.Dir(name), 0555) +} +func DenyRemove(t *testing.T, directory string) func() { + t.Helper() + return denyMode(t, directory, 0500) +} diff --git a/internal/testutil/filesystem_windows.go b/internal/testutil/filesystem_windows.go new file mode 100644 index 0000000..48b2a7e --- /dev/null +++ b/internal/testutil/filesystem_windows.go @@ -0,0 +1,41 @@ +// Package testutil provides real filesystem failure conditions for platform tests. +package testutil + +import ( + "sync" + "testing" + + "golang.org/x/sys/windows" +) + +func denySharing(t *testing.T, name string, share uint32) func() { + t.Helper() + path, err := windows.UTF16PtrFromString(name) + if err != nil { + t.Fatal(err) + } + handle, err := windows.CreateFile(path, windows.GENERIC_READ, share, nil, windows.OPEN_EXISTING, windows.FILE_FLAG_BACKUP_SEMANTICS, 0) + if err != nil { + t.Fatal(err) + } + release := sync.OnceFunc(func() { + if err := windows.CloseHandle(handle); err != nil { + t.Error(err) + } + }) + t.Cleanup(release) + return release +} + +func DenyRead(t *testing.T, name string) func() { + t.Helper() + return denySharing(t, name, 0) +} +func DenyReplace(t *testing.T, name string) func() { + t.Helper() + return denySharing(t, name, windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE) +} +func DenyRemove(t *testing.T, directory string) func() { + t.Helper() + return denySharing(t, directory, windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE) +} diff --git a/internal/vault/export_notes_test.go b/internal/vault/export_notes_test.go index 0d539bb..239ae3a 100644 --- a/internal/vault/export_notes_test.go +++ b/internal/vault/export_notes_test.go @@ -62,6 +62,8 @@ func TestExportNotesKeepsMarkdownAndReportsMissingAssets(t *testing.T) { func TestExportNotesExcludesItsOwnOutputInsideVault(t *testing.T) { v := newVault(t) t.Setenv("TMPDIR", v.Root()) + t.Setenv("TMP", v.Root()) + t.Setenv("TEMP", v.Root()) if _, err := v.Write("note.md", []byte("source"), Precondition{}); err != nil { t.Fatal(err) } diff --git a/internal/vault/path_windows_test.go b/internal/vault/path_windows_test.go new file mode 100644 index 0000000..0c39764 --- /dev/null +++ b/internal/vault/path_windows_test.go @@ -0,0 +1,15 @@ +package vault + +import ( + "errors" + "testing" +) + +func TestRejectWindowsDeviceAndAliasPaths(t *testing.T) { + v := newVault(t) + for _, name := range []string{"C:escape.md", "note.md:stream", "CON", "dir/NUL.md", "dir./note.md", "dir /note.md"} { + if _, err := v.Write(name, []byte("must not write"), Precondition{}); !errors.Is(err, ErrInvalidPath) { + t.Errorf("unsafe Windows path %q: %v", name, err) + } + } +} diff --git a/internal/vault/trash_test.go b/internal/vault/trash_test.go index cd7c958..3819570 100644 --- a/internal/vault/trash_test.go +++ b/internal/vault/trash_test.go @@ -6,15 +6,15 @@ import ( "errors" "os" "path/filepath" + "rawmark/internal/testutil" + "runtime" "sync" "testing" "time" ) func TestTrashPurgeFailurePreservesMetadata(t *testing.T) { - if os.Geteuid() == 0 { - t.Skip("目录写权限测试需以普通用户运行;root 可绕过文件权限") - } + for _, operation := range []string{"single", "all", "expired"} { for _, blocked := range []string{"payload", "parent"} { t.Run(operation+"/"+blocked, func(t *testing.T) { @@ -47,11 +47,11 @@ func TestTrashPurgeFailurePreservesMetadata(t *testing.T) { locked := filepath.Join(v.trash, item.ID, "folder", "locked") if blocked == "parent" { locked = v.trash + if runtime.GOOS == "windows" { + locked = filepath.Join(v.trash, item.ID) + } } - if err := os.Chmod(locked, 0500); err != nil { - t.Fatal(err) - } - t.Cleanup(func() { os.Chmod(locked, 0755) }) + allowRemove := testutil.DenyRemove(t, locked) purge := func() error { if operation == "single" { return v.Purge(item.ID) @@ -80,9 +80,7 @@ func TestTrashPurgeFailurePreservesMetadata(t *testing.T) { if err != nil || len(items) != 1 || items[0].ID != item.ID { t.Fatalf("删除失败的条目必须仍可列出: %+v %v", items, err) } - if err := os.Chmod(locked, 0755); err != nil { - t.Fatal(err) - } + allowRemove() if err := purge(); err != nil { t.Fatalf("修复权限后应可继续清理: %v", err) } diff --git a/internal/vault/tree_icons_test.go b/internal/vault/tree_icons_test.go index caa38ed..b91e4ef 100644 --- a/internal/vault/tree_icons_test.go +++ b/internal/vault/tree_icons_test.go @@ -4,6 +4,7 @@ import ( "errors" "os" "path/filepath" + "rawmark/internal/testutil" "reflect" "strings" "testing" @@ -145,9 +146,7 @@ func TestTreeIconValidationAndCorruptMetadata(t *testing.T) { } func TestTreeIconWriteFailureRollsBack(t *testing.T) { - if os.Geteuid() == 0 { - t.Skip("目录写权限测试需以普通用户运行;root 可绕过文件权限") - } + for _, operation := range []string{"set", "move", "delete", "restore"} { t.Run(operation, func(t *testing.T) { v := newVault(t) @@ -169,10 +168,7 @@ func TestTreeIconWriteFailureRollsBack(t *testing.T) { if err != nil { t.Fatal(err) } - if err := os.Chmod(filepath.Join(v.root, metaDir), 0555); err != nil { - t.Fatal(err) - } - defer os.Chmod(filepath.Join(v.root, metaDir), 0755) + testutil.DenyReplace(t, filepath.Join(v.root, metaDir, "tree-icons.json")) switch operation { case "set": err = v.SetTreeIcon("a.md", &TreeIcon{"builtin", "folder"}) diff --git a/internal/vault/vault.go b/internal/vault/vault.go index c6f89bf..7601182 100644 --- a/internal/vault/vault.go +++ b/internal/vault/vault.go @@ -15,6 +15,7 @@ import ( "os" "path" "path/filepath" + "rawmark/internal/fileutil" "rawmark/internal/i18n" "regexp" "sort" @@ -97,7 +98,7 @@ func Rev(content []byte) string { // CleanPath validates client-relative paths: reject absolute paths, empty segments, "."/"..", and hidden segments starting with ".". // Hidden segments include .rawmark metadata and .git; none are exposed through the API. func CleanPath(p string) (string, error) { - if p == "" || strings.HasPrefix(p, "/") || strings.ContainsAny(p, "\\\x00") { + if !fileutil.IsLocalPath(p) || strings.ContainsAny(p, "\\\x00") { return "", ErrInvalidPath } for _, seg := range strings.Split(p, "/") { diff --git a/internal/vault/vault_test.go b/internal/vault/vault_test.go index eb98323..3493476 100644 --- a/internal/vault/vault_test.go +++ b/internal/vault/vault_test.go @@ -5,6 +5,7 @@ import ( "net/url" "os" "path/filepath" + "runtime" "strings" "testing" ) @@ -130,7 +131,11 @@ func TestRestoreRollsBackWhenAssetsAreMissing(t *testing.T) { func TestAssetLinksWithReservedNotePath(t *testing.T) { v := newVault(t) - link, err := v.SaveAsset("目录 #?%20/n.md", "x.png", strings.NewReader("image"), 100) + folder := "目录 #?%20" + if runtime.GOOS == "windows" { + folder = "目录 #%20" + } + link, err := v.SaveAsset(folder+"/n.md", "x.png", strings.NewReader("image"), 100) if err != nil { t.Fatal(err) } @@ -138,7 +143,7 @@ func TestAssetLinksWithReservedNotePath(t *testing.T) { if err != nil { t.Fatal(err) } - file, _, err := v.OpenRaw(filepath.ToSlash(filepath.Join("目录 #?%20", decoded))) + file, _, err := v.OpenRaw(filepath.ToSlash(filepath.Join(folder, decoded))) if err != nil { t.Fatalf("资源链接未指向实际文件: %s %v", link, err) } diff --git a/internal/watch/watch_test.go b/internal/watch/watch_test.go index 00fa99f..933841e 100644 --- a/internal/watch/watch_test.go +++ b/internal/watch/watch_test.go @@ -3,6 +3,7 @@ package watch import ( "os" "path/filepath" + "rawmark/internal/testutil" "sort" "strings" "testing" @@ -14,9 +15,7 @@ import ( ) func TestUnreadableNotePreservesSnapshot(t *testing.T) { - if os.Geteuid() == 0 { - t.Skip("权限回归需要以非 root 用户运行") - } + v, err := vault.Open(t.TempDir()) if err != nil { t.Fatal(err) @@ -43,10 +42,7 @@ func TestUnreadableNotePreservesSnapshot(t *testing.T) { if err := os.WriteFile(p, []byte("修改后的较长正文"), 0o644); err != nil { t.Fatal(err) } - if err := os.Chmod(p, 0); err != nil { - t.Fatal(err) - } - defer os.Chmod(p, 0o644) + allowRead := testutil.DenyRead(t, p) w.rescan() if w.snap["note.md"].rev != old || scans != 0 { t.Fatal("读取失败不能提交不完整快照或更新索引") @@ -56,9 +52,7 @@ func TestUnreadableNotePreservesSnapshot(t *testing.T) { t.Fatalf("读取失败不能广播删除事件: %s", msg) default: } - if err := os.Chmod(p, 0o644); err != nil { - t.Fatal(err) - } + allowRead() w.rescan() if w.snap["note.md"].rev == old || scans != 1 { t.Fatal("恢复读取权限后应同步新版本")