fix(platform): support Windows storage and native filesystem boundaries

This commit is contained in:
eezd committed 2026-10-27 14:47:25 +08:00
1 parent 3726423338
commit e239fff253
24 files changed
+353 -49

No files matched your search

+11 -1
View File
@@ -17,6 +17,7 @@ import (
"os"
"os/signal"
"path/filepath"
"runtime"
"sync"
"syscall"
"time"
@@ -77,9 +78,15 @@ func run(addr string) error {
if err = os.MkdirAll(tempDir, 0700); err != nil {
return err
}
if err = os.Setenv("TMPDIR", tempDir); err != nil {
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 {
return err
@@ -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
+1 -1
View File
@@ -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
+2
View File
@@ -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)
+4 -3
View File
@@ -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
+3 -2
View File
@@ -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
+38
View File
@@ -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")
}
})
}
}
+5 -4
View File
@@ -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{}
+32
View File
@@ -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)
}
})
}
}
+20
View File
@@ -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())
}
+16
View File
@@ -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)
}
+1 -9
View File
@@ -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))
+36
View File
@@ -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)
}
}
}
+30
View File
@@ -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
}
+14
View File
@@ -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
}
+14
View File
@@ -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
}
+45
View File
@@ -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)
}
+41
View File
@@ -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)
}
+2
View File
@@ -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)
}
+15
View File
@@ -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)
}
}
}
+7 -9
View File
@@ -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)
}
+3 -7
View File
@@ -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"})
+2 -1
View File
@@ -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, "/") {
+7 -2
View File
@@ -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)
}
+4 -10
View File
@@ -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("恢复读取权限后应同步新版本")