Preserve vault state on failed file operations
This commit is contained in:
1 parent
7e52d9a00b
commit
df064155c7
4 files changed
+212
-18
No files matched your search
@@ -4,6 +4,7 @@ package api
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
@@ -359,6 +360,9 @@ func (s *Server) move(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.store.MoveShares(body.From, body.To); err != nil {
|
if err := s.store.MoveShares(body.From, body.To); err != nil {
|
||||||
|
if rollback := s.vault.Move(body.To, body.From); rollback != nil {
|
||||||
|
err = errors.Join(err, fmt.Errorf("回滚笔记移动失败: %w", rollback))
|
||||||
|
}
|
||||||
internalError(w, r, err)
|
internalError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,36 @@ type fixture struct {
|
|||||||
admin *http.Client
|
admin *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMoveRollsBackWhenShareUpdateFails(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
v, err := vault.Open(filepath.Join(dir, "vault"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := v.Write("from.md", []byte("原文"), vault.Precondition{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
st, err := store.Open(filepath.Join(dir, "test.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := st.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s := New(v, nil, st, nil, nil)
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
s.move(res, httptest.NewRequest("POST", "/api/v1/move", strings.NewReader(`{"from":"from.md","to":"to.md"}`)))
|
||||||
|
if res.Code != 500 {
|
||||||
|
t.Fatalf("数据库失败应返回错误: %d", res.Code)
|
||||||
|
}
|
||||||
|
if content, _, err := v.Read("from.md"); err != nil || string(content) != "原文" {
|
||||||
|
t.Fatalf("原笔记未恢复: %q %v", content, err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(v.Root(), "to.md")); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("失败后目标仍存在: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func newFixture(t *testing.T) *fixture {
|
func newFixture(t *testing.T) *fixture {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
|
|||||||
+91
-18
@@ -338,7 +338,7 @@ func (v *Vault) Tree() ([]Entry, error) {
|
|||||||
// ---- 移动 / 重命名 ----
|
// ---- 移动 / 重命名 ----
|
||||||
|
|
||||||
// Move 移动或重命名笔记/文件夹。笔记的 .assets 目录随之移动;笔记改名时同步改写正文中指向自身图片目录的链接
|
// Move 移动或重命名笔记/文件夹。笔记的 .assets 目录随之移动;笔记改名时同步改写正文中指向自身图片目录的链接
|
||||||
func (v *Vault) Move(from, to string) error {
|
func (v *Vault) Move(from, to string) (err error) {
|
||||||
src, err := v.abs(from)
|
src, err := v.abs(from)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -381,10 +381,25 @@ func (v *Vault) Move(from, to string) error {
|
|||||||
if err := os.Rename(src, dst); err != nil {
|
if err := os.Rename(src, dst); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
assetsMoved := false
|
||||||
|
defer func() {
|
||||||
|
if err == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if assetsMoved {
|
||||||
|
if rollback := os.Rename(dstAssets, srcAssets); rollback != nil {
|
||||||
|
err = errors.Join(err, fmt.Errorf("回滚图片移动失败: %w", rollback))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if rollback := os.Rename(dst, src); rollback != nil {
|
||||||
|
err = errors.Join(err, fmt.Errorf("回滚笔记移动失败: %w", rollback))
|
||||||
|
}
|
||||||
|
}()
|
||||||
if srcAssets != "" {
|
if srcAssets != "" {
|
||||||
if err := os.Rename(srcAssets, dstAssets); err != nil {
|
if err := os.Rename(srcAssets, dstAssets); err != nil {
|
||||||
return fmt.Errorf("移动图片目录失败: %w", err)
|
return fmt.Errorf("移动图片目录失败: %w", err)
|
||||||
}
|
}
|
||||||
|
assetsMoved = true
|
||||||
oldBase := strings.TrimSuffix(path.Base(from), NoteExt)
|
oldBase := strings.TrimSuffix(path.Base(from), NoteExt)
|
||||||
newBase := strings.TrimSuffix(path.Base(to), NoteExt)
|
newBase := strings.TrimSuffix(path.Base(to), NoteExt)
|
||||||
if oldBase != newBase {
|
if oldBase != newBase {
|
||||||
@@ -411,7 +426,7 @@ func newTrashID() string {
|
|||||||
var trashIDPattern = regexp.MustCompile(`^\d{8}T\d{6}\.\d{3}-[0-9a-f]{8}$`)
|
var trashIDPattern = regexp.MustCompile(`^\d{8}T\d{6}\.\d{3}-[0-9a-f]{8}$`)
|
||||||
|
|
||||||
// Delete 把笔记(连同 .assets)或文件夹移入回收站
|
// Delete 把笔记(连同 .assets)或文件夹移入回收站
|
||||||
func (v *Vault) Delete(rel string) (TrashItem, error) {
|
func (v *Vault) Delete(rel string) (item TrashItem, err error) {
|
||||||
p, err := v.abs(rel)
|
p, err := v.abs(rel)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return TrashItem{}, err
|
return TrashItem{}, err
|
||||||
@@ -428,25 +443,53 @@ func (v *Vault) Delete(rel string) (TrashItem, error) {
|
|||||||
if !info.IsDir() && !IsNote(rel) {
|
if !info.IsDir() && !IsNote(rel) {
|
||||||
return TrashItem{}, ErrInvalidPath
|
return TrashItem{}, ErrInvalidPath
|
||||||
}
|
}
|
||||||
item := TrashItem{ID: newTrashID(), Path: rel, Dir: info.IsDir(), DeletedAt: time.Now().UnixMilli()}
|
item = TrashItem{ID: newTrashID(), Path: rel, Dir: info.IsDir(), DeletedAt: time.Now().UnixMilli()}
|
||||||
|
if !item.Dir {
|
||||||
|
st, statErr := os.Stat(AssetsDir(p))
|
||||||
|
if statErr != nil && !errors.Is(statErr, fs.ErrNotExist) {
|
||||||
|
return TrashItem{}, statErr
|
||||||
|
}
|
||||||
|
if statErr == nil && st.IsDir() {
|
||||||
|
item.Assets = filepath.Base(AssetsDir(p))
|
||||||
|
}
|
||||||
|
}
|
||||||
dir := filepath.Join(v.trash, item.ID)
|
dir := filepath.Join(v.trash, item.ID)
|
||||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
return TrashItem{}, err
|
return TrashItem{}, err
|
||||||
}
|
}
|
||||||
if err := os.Rename(p, filepath.Join(dir, filepath.Base(p))); err != nil {
|
noteMoved := false
|
||||||
os.Remove(dir)
|
defer func() {
|
||||||
return TrashItem{}, err
|
if err == nil {
|
||||||
}
|
return
|
||||||
if !item.Dir {
|
}
|
||||||
if st, err := os.Stat(AssetsDir(p)); err == nil && st.IsDir() {
|
rollbackFailed := false
|
||||||
item.Assets = filepath.Base(AssetsDir(p))
|
if noteMoved {
|
||||||
if err := os.Rename(AssetsDir(p), filepath.Join(dir, item.Assets)); err != nil {
|
if rollback := os.Rename(filepath.Join(dir, filepath.Base(p)), p); rollback != nil {
|
||||||
return TrashItem{}, fmt.Errorf("移动图片目录到回收站失败: %w", err)
|
err = errors.Join(err, fmt.Errorf("回滚回收站笔记失败: %w", rollback))
|
||||||
|
rollbackFailed = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
// 回滚失败时保留目录和元数据,避免继续删除仅存的恢复依据。
|
||||||
|
if !rollbackFailed {
|
||||||
|
if cleanup := os.RemoveAll(dir); cleanup != nil {
|
||||||
|
err = errors.Join(err, cleanup)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
meta, _ := json.Marshal(item)
|
meta, _ := json.Marshal(item)
|
||||||
return item, atomicWrite(filepath.Join(dir, "meta.json"), meta)
|
if err = atomicWrite(filepath.Join(dir, "meta.json"), meta); err != nil {
|
||||||
|
return item, err
|
||||||
|
}
|
||||||
|
if err = os.Rename(p, filepath.Join(dir, filepath.Base(p))); err != nil {
|
||||||
|
return item, err
|
||||||
|
}
|
||||||
|
noteMoved = true
|
||||||
|
if item.Assets != "" {
|
||||||
|
if err = os.Rename(AssetsDir(p), filepath.Join(dir, item.Assets)); err != nil {
|
||||||
|
return item, fmt.Errorf("移动图片目录到回收站失败: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return item, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *Vault) readTrashItem(id string) (TrashItem, error) {
|
func (v *Vault) readTrashItem(id string) (TrashItem, error) {
|
||||||
@@ -487,8 +530,8 @@ func (v *Vault) TrashList() ([]TrashItem, error) {
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *Vault) Restore(id string) (TrashItem, error) {
|
func (v *Vault) Restore(id string) (item TrashItem, err error) {
|
||||||
item, err := v.readTrashItem(id)
|
item, err = v.readTrashItem(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return TrashItem{}, err
|
return TrashItem{}, err
|
||||||
}
|
}
|
||||||
@@ -513,11 +556,38 @@ func (v *Vault) Restore(id string) (TrashItem, error) {
|
|||||||
if err := os.Rename(filepath.Join(dir, filepath.Base(dst)), dst); err != nil {
|
if err := os.Rename(filepath.Join(dir, filepath.Base(dst)), dst); err != nil {
|
||||||
return TrashItem{}, err
|
return TrashItem{}, err
|
||||||
}
|
}
|
||||||
|
assetsMoved, cleaning := false, false
|
||||||
|
defer func() {
|
||||||
|
if err == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if cleaning {
|
||||||
|
// 清理可能已删除元数据;先重建恢复依据,再撤回本次移动。
|
||||||
|
if rollback := os.MkdirAll(dir, 0o755); rollback != nil {
|
||||||
|
err = errors.Join(err, rollback)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
meta, _ := json.Marshal(item)
|
||||||
|
if rollback := atomicWrite(filepath.Join(dir, "meta.json"), meta); rollback != nil {
|
||||||
|
err = errors.Join(err, rollback)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if assetsMoved {
|
||||||
|
if rollback := os.Rename(AssetsDir(dst), filepath.Join(dir, item.Assets)); rollback != nil {
|
||||||
|
err = errors.Join(err, fmt.Errorf("回滚恢复图片失败: %w", rollback))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if rollback := os.Rename(dst, filepath.Join(dir, filepath.Base(dst))); rollback != nil {
|
||||||
|
err = errors.Join(err, fmt.Errorf("回滚恢复笔记失败: %w", rollback))
|
||||||
|
}
|
||||||
|
}()
|
||||||
if item.Assets != "" {
|
if item.Assets != "" {
|
||||||
if err := os.Rename(filepath.Join(dir, item.Assets), AssetsDir(dst)); err != nil {
|
if err := os.Rename(filepath.Join(dir, item.Assets), AssetsDir(dst)); err != nil {
|
||||||
return TrashItem{}, fmt.Errorf("恢复图片目录失败: %w", err)
|
return item, fmt.Errorf("恢复图片目录失败: %w", err)
|
||||||
}
|
}
|
||||||
|
assetsMoved = true
|
||||||
}
|
}
|
||||||
|
cleaning = true
|
||||||
return item, os.RemoveAll(dir)
|
return item, os.RemoveAll(dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -558,6 +628,9 @@ func (v *Vault) SaveAsset(note, origName string, r io.Reader, limit int64) (stri
|
|||||||
dir := AssetsDir(p)
|
dir := AssetsDir(p)
|
||||||
v.mu.Lock()
|
v.mu.Lock()
|
||||||
defer v.mu.Unlock()
|
defer v.mu.Unlock()
|
||||||
|
if err := v.checkInside(filepath.Join(dir, name)); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -592,4 +665,4 @@ func EscapeLinkPath(s string) string {
|
|||||||
return linkEscaper.Replace(s)
|
return linkEscaper.Replace(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
var linkEscaper = strings.NewReplacer(" ", "%20", "(", "%28", ")", "%29", "<", "%3C", ">", "%3E")
|
var linkEscaper = strings.NewReplacer("%", "%25", "#", "%23", "?", "%3F", " ", "%20", "(", "%28", ")", "%29", "<", "%3C", ">", "%3E")
|
||||||
@@ -2,6 +2,7 @@ package vault
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -44,6 +45,92 @@ func TestSymlinkEscapeRejected(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAssetSymlinkEscapeRejected(t *testing.T) {
|
||||||
|
v := newVault(t)
|
||||||
|
outside := t.TempDir()
|
||||||
|
if err := os.Symlink(outside, filepath.Join(v.Root(), "n.assets")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := v.SaveAsset("n.md", "x.png", strings.NewReader("image"), 100); !errors.Is(err, ErrInvalidPath) {
|
||||||
|
t.Fatalf("资源目录越界应被拒绝: %v", err)
|
||||||
|
}
|
||||||
|
files, err := os.ReadDir(outside)
|
||||||
|
if err != nil || len(files) != 0 {
|
||||||
|
t.Fatalf("仓库外被写入: %v %v", files, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMoveRollsBackWhenAssetDestinationIsTooLong(t *testing.T) {
|
||||||
|
v := newVault(t)
|
||||||
|
if _, err := v.Write("n.md", []byte("原文"), Precondition{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := v.SaveAsset("n.md", "x.png", strings.NewReader("image"), 100); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// 笔记名可写入,但派生的 .assets 名超过文件系统的单段长度限制。
|
||||||
|
destination := strings.Repeat("a", 250) + ".md"
|
||||||
|
if err := v.Move("n.md", destination); err == nil {
|
||||||
|
t.Fatal("图片目录移动应失败")
|
||||||
|
}
|
||||||
|
if content, _, err := v.Read("n.md"); err != nil || string(content) != "原文" {
|
||||||
|
t.Fatalf("原笔记未恢复: %q %v", content, err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(v.Root(), "n.assets")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, _, err := v.Read(destination); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Fatalf("目标笔记应撤回: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestoreRollsBackWhenAssetsAreMissing(t *testing.T) {
|
||||||
|
v := newVault(t)
|
||||||
|
if _, err := v.Write("n.md", []byte("原文"), Precondition{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := v.SaveAsset("n.md", "x.png", strings.NewReader("image"), 100); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
item, err := v.Delete("n.md")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.RemoveAll(filepath.Join(v.trash, item.ID, item.Assets)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := v.Restore(item.ID); err == nil {
|
||||||
|
t.Fatal("丢失图片目录时恢复应失败")
|
||||||
|
}
|
||||||
|
if _, _, err := v.Read("n.md"); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Fatalf("不应留下部分恢复的笔记: %v", err)
|
||||||
|
}
|
||||||
|
items, err := v.TrashList()
|
||||||
|
if err != nil || len(items) != 1 {
|
||||||
|
t.Fatalf("回收站恢复依据未保留: %v %v", items, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAssetLinksEncodeReservedURLCharacters(t *testing.T) {
|
||||||
|
v := newVault(t)
|
||||||
|
link, err := v.SaveAsset("n#?%20.md", "x.png", strings.NewReader("image"), 100)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
decoded, err := url.PathUnescape(link)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
file, _, err := v.OpenRaw(decoded)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("资源链接未指向实际文件: %s %v", link, err)
|
||||||
|
}
|
||||||
|
file.Close()
|
||||||
|
if strings.ContainsAny(link, "#?") || !strings.HasPrefix(link, "n%23%3F%2520.assets/") {
|
||||||
|
t.Fatalf("链接未正确转义: %s", link)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestWritePreconditions(t *testing.T) {
|
func TestWritePreconditions(t *testing.T) {
|
||||||
v := newVault(t)
|
v := newVault(t)
|
||||||
rev, err := v.Write("a/b.md", []byte("one"), Precondition{IfNoneMatch: true})
|
rev, err := v.Write("a/b.md", []byte("one"), Precondition{IfNoneMatch: true})
|
||||||
|
|||||||
Reference in new issue
Block a user