fix(platform): support Windows storage and native filesystem boundaries
This commit is contained in:
1 parent
3726423338
commit
e239fff253
24 files changed
+353
-49
No files matched your search
+11
-1
@@ -17,6 +17,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"sync"
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
@@ -77,9 +78,15 @@ func run(addr string) error {
|
|||||||
if err = os.MkdirAll(tempDir, 0700); err != nil {
|
if err = os.MkdirAll(tempDir, 0700); err != nil {
|
||||||
return err
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
}
|
||||||
logs, err := backup.OpenLog(filepath.Join(dataDir, "logs"))
|
logs, err := backup.OpenLog(filepath.Join(dataDir, "logs"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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.
|
// permissionHint adds guidance for permission errors: Docker bind mounts are often not writable by non-root container users.
|
||||||
func permissionHint(err error) error {
|
func permissionHint(err error) error {
|
||||||
if errors.Is(err, fs.ErrPermission) {
|
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 fmt.Errorf("%w(当前 UID %d 对数据目录没有写权限:请用 --user 指定宿主机目录所有者的 UID:GID,或执行 chown 修改目录所有者)", err, os.Getuid())
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ require (
|
|||||||
github.com/yuin/goldmark/v2 v2.1.8
|
github.com/yuin/goldmark/v2 v2.1.8
|
||||||
go.yaml.in/yaml/v3 v3.0.5
|
go.yaml.in/yaml/v3 v3.0.5
|
||||||
golang.org/x/crypto v0.58.0
|
golang.org/x/crypto v0.58.0
|
||||||
|
golang.org/x/sys v0.49.0
|
||||||
modernc.org/sqlite v1.60.1
|
modernc.org/sqlite v1.60.1
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -18,7 +19,6 @@ require (
|
|||||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // 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/libc v1.77.1 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.12.1 // indirect
|
modernc.org/memory v1.12.1 // indirect
|
||||||
|
|||||||
@@ -78,6 +78,8 @@ func TestPortableNotesExcludeApplicationData(t *testing.T) {
|
|||||||
}
|
}
|
||||||
// As in the real process, export temporary files live inside the application data directory.
|
// As in the real process, export temporary files live inside the application data directory.
|
||||||
t.Setenv("TMPDIR", filepath.Join(data, "temp"))
|
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)
|
res = f.do(f.admin, "POST", "/api/v1/maintenance/notes/export", "", nil)
|
||||||
defer res.Body.Close()
|
defer res.Body.Close()
|
||||||
assertOK(t, res)
|
assertOK(t, res)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"rawmark/internal/fileutil"
|
||||||
"rawmark/internal/i18n"
|
"rawmark/internal/i18n"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -65,11 +66,11 @@ func saveUpload(dir string, meta maintenanceUpload) error {
|
|||||||
return os.WriteFile(filepath.Join(dir, "upload.json"), data, 0600)
|
return os.WriteFile(filepath.Join(dir, "upload.json"), data, 0600)
|
||||||
}
|
}
|
||||||
func enoughSpace(dir string, size int64) error {
|
func enoughSpace(dir string, size int64) error {
|
||||||
var stat syscall.Statfs_t
|
available, err := fileutil.AvailableBytes(dir)
|
||||||
if err := syscall.Statfs(dir, &stat); err != nil {
|
if err != nil {
|
||||||
return err
|
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 i18n.New("not_enough_disk_space_w_a39b20", syscall.ENOSPC)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"rawmark/internal/fileutil"
|
||||||
"rawmark/internal/i18n"
|
"rawmark/internal/i18n"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -42,7 +43,7 @@ type pluginInfo struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func validPluginID(id string) bool {
|
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) {
|
func parsePluginManifest(data []byte) (pluginManifest, error) {
|
||||||
@@ -181,7 +182,7 @@ func unpackPlugin(data []byte, destination string) (pluginInfo, error) {
|
|||||||
var total int64
|
var total int64
|
||||||
for _, file := range archive.File {
|
for _, file := range archive.File {
|
||||||
name := strings.TrimSuffix(file.Name, "/")
|
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")
|
return info, i18n.New("plugin_contains_invalid_paths_duplicate_6a1e05")
|
||||||
}
|
}
|
||||||
seen[name] = true
|
seen[name] = true
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"rawmark/internal/fileutil"
|
||||||
"rawmark/internal/i18n"
|
"rawmark/internal/i18n"
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
@@ -42,7 +43,7 @@ type Manifest struct {
|
|||||||
type Source struct{ Name, Path string }
|
type Source struct{ Name, Path string }
|
||||||
|
|
||||||
func validPath(name string) bool {
|
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 {
|
func validEntry(kind, name string) bool {
|
||||||
if !validPath(name) {
|
if !validPath(name) {
|
||||||
@@ -237,11 +238,11 @@ func Extract(ctx context.Context, archive, dest string) (Manifest, error) {
|
|||||||
}
|
}
|
||||||
expanded += e.Size
|
expanded += e.Size
|
||||||
}
|
}
|
||||||
var disk syscall.Statfs_t
|
available, err := fileutil.AvailableBytes(dest)
|
||||||
if err := syscall.Statfs(dest, &disk); err != nil {
|
if err != nil {
|
||||||
return manifest, err
|
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)
|
return manifest, i18n.New("not_enough_disk_space_w_a39b20", syscall.ENOSPC)
|
||||||
}
|
}
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -51,15 +51,7 @@ func WriteRecord(data string, record RestoreRecord) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err = os.Rename(f.Name(), dest); err != nil {
|
return publishRecord(f.Name(), dest)
|
||||||
return err
|
|
||||||
}
|
|
||||||
dir, err := os.Open(filepath.Dir(dest))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer dir.Close()
|
|
||||||
return dir.Sync()
|
|
||||||
}
|
}
|
||||||
func Pending(data string) (bool, error) {
|
func Pending(data string) (bool, error) {
|
||||||
_, err := os.Stat(recordPath(data))
|
_, err := os.Stat(recordPath(data))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -62,6 +62,8 @@ func TestExportNotesKeepsMarkdownAndReportsMissingAssets(t *testing.T) {
|
|||||||
func TestExportNotesExcludesItsOwnOutputInsideVault(t *testing.T) {
|
func TestExportNotesExcludesItsOwnOutputInsideVault(t *testing.T) {
|
||||||
v := newVault(t)
|
v := newVault(t)
|
||||||
t.Setenv("TMPDIR", v.Root())
|
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 {
|
if _, err := v.Write("note.md", []byte("source"), Precondition{}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,15 +6,15 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"rawmark/internal/testutil"
|
||||||
|
"runtime"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestTrashPurgeFailurePreservesMetadata(t *testing.T) {
|
func TestTrashPurgeFailurePreservesMetadata(t *testing.T) {
|
||||||
if os.Geteuid() == 0 {
|
|
||||||
t.Skip("目录写权限测试需以普通用户运行;root 可绕过文件权限")
|
|
||||||
}
|
|
||||||
for _, operation := range []string{"single", "all", "expired"} {
|
for _, operation := range []string{"single", "all", "expired"} {
|
||||||
for _, blocked := range []string{"payload", "parent"} {
|
for _, blocked := range []string{"payload", "parent"} {
|
||||||
t.Run(operation+"/"+blocked, func(t *testing.T) {
|
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")
|
locked := filepath.Join(v.trash, item.ID, "folder", "locked")
|
||||||
if blocked == "parent" {
|
if blocked == "parent" {
|
||||||
locked = v.trash
|
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 {
|
purge := func() error {
|
||||||
if operation == "single" {
|
if operation == "single" {
|
||||||
return v.Purge(item.ID)
|
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 {
|
if err != nil || len(items) != 1 || items[0].ID != item.ID {
|
||||||
t.Fatalf("删除失败的条目必须仍可列出: %+v %v", items, err)
|
t.Fatalf("删除失败的条目必须仍可列出: %+v %v", items, err)
|
||||||
}
|
}
|
||||||
if err := os.Chmod(locked, 0755); err != nil {
|
allowRemove()
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := purge(); err != nil {
|
if err := purge(); err != nil {
|
||||||
t.Fatalf("修复权限后应可继续清理: %v", err)
|
t.Fatalf("修复权限后应可继续清理: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"rawmark/internal/testutil"
|
||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -145,9 +146,7 @@ func TestTreeIconValidationAndCorruptMetadata(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestTreeIconWriteFailureRollsBack(t *testing.T) {
|
func TestTreeIconWriteFailureRollsBack(t *testing.T) {
|
||||||
if os.Geteuid() == 0 {
|
|
||||||
t.Skip("目录写权限测试需以普通用户运行;root 可绕过文件权限")
|
|
||||||
}
|
|
||||||
for _, operation := range []string{"set", "move", "delete", "restore"} {
|
for _, operation := range []string{"set", "move", "delete", "restore"} {
|
||||||
t.Run(operation, func(t *testing.T) {
|
t.Run(operation, func(t *testing.T) {
|
||||||
v := newVault(t)
|
v := newVault(t)
|
||||||
@@ -169,10 +168,7 @@ func TestTreeIconWriteFailureRollsBack(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := os.Chmod(filepath.Join(v.root, metaDir), 0555); err != nil {
|
testutil.DenyReplace(t, filepath.Join(v.root, metaDir, "tree-icons.json"))
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
defer os.Chmod(filepath.Join(v.root, metaDir), 0755)
|
|
||||||
switch operation {
|
switch operation {
|
||||||
case "set":
|
case "set":
|
||||||
err = v.SetTreeIcon("a.md", &TreeIcon{"builtin", "folder"})
|
err = v.SetTreeIcon("a.md", &TreeIcon{"builtin", "folder"})
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"rawmark/internal/fileutil"
|
||||||
"rawmark/internal/i18n"
|
"rawmark/internal/i18n"
|
||||||
"regexp"
|
"regexp"
|
||||||
"sort"
|
"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 ".".
|
// 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.
|
// Hidden segments include .rawmark metadata and .git; none are exposed through the API.
|
||||||
func CleanPath(p string) (string, error) {
|
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
|
return "", ErrInvalidPath
|
||||||
}
|
}
|
||||||
for _, seg := range strings.Split(p, "/") {
|
for _, seg := range strings.Split(p, "/") {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
@@ -130,7 +131,11 @@ func TestRestoreRollsBackWhenAssetsAreMissing(t *testing.T) {
|
|||||||
|
|
||||||
func TestAssetLinksWithReservedNotePath(t *testing.T) {
|
func TestAssetLinksWithReservedNotePath(t *testing.T) {
|
||||||
v := newVault(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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -138,7 +143,7 @@ func TestAssetLinksWithReservedNotePath(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("资源链接未指向实际文件: %s %v", link, err)
|
t.Fatalf("资源链接未指向实际文件: %s %v", link, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package watch
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"rawmark/internal/testutil"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -14,9 +15,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestUnreadableNotePreservesSnapshot(t *testing.T) {
|
func TestUnreadableNotePreservesSnapshot(t *testing.T) {
|
||||||
if os.Geteuid() == 0 {
|
|
||||||
t.Skip("权限回归需要以非 root 用户运行")
|
|
||||||
}
|
|
||||||
v, err := vault.Open(t.TempDir())
|
v, err := vault.Open(t.TempDir())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -43,10 +42,7 @@ func TestUnreadableNotePreservesSnapshot(t *testing.T) {
|
|||||||
if err := os.WriteFile(p, []byte("修改后的较长正文"), 0o644); err != nil {
|
if err := os.WriteFile(p, []byte("修改后的较长正文"), 0o644); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := os.Chmod(p, 0); err != nil {
|
allowRead := testutil.DenyRead(t, p)
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
defer os.Chmod(p, 0o644)
|
|
||||||
w.rescan()
|
w.rescan()
|
||||||
if w.snap["note.md"].rev != old || scans != 0 {
|
if w.snap["note.md"].rev != old || scans != 0 {
|
||||||
t.Fatal("读取失败不能提交不完整快照或更新索引")
|
t.Fatal("读取失败不能提交不完整快照或更新索引")
|
||||||
@@ -56,9 +52,7 @@ func TestUnreadableNotePreservesSnapshot(t *testing.T) {
|
|||||||
t.Fatalf("读取失败不能广播删除事件: %s", msg)
|
t.Fatalf("读取失败不能广播删除事件: %s", msg)
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
if err := os.Chmod(p, 0o644); err != nil {
|
allowRead()
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
w.rescan()
|
w.rescan()
|
||||||
if w.snap["note.md"].rev == old || scans != 1 {
|
if w.snap["note.md"].rev == old || scans != 1 {
|
||||||
t.Fatal("恢复读取权限后应同步新版本")
|
t.Fatal("恢复读取权限后应同步新版本")
|
||||||
|
|||||||
Reference in new issue
Block a user