Add encrypted note storage and keyring endpoints

- Treat name.md.enc as encrypted notes; forbid coexisting twins sharing assets
- Convert between plain and encrypted notes via PUT ?convertFrom
- Store keyring at .rawmark/keyring.json with versioned writes
- Allow permanent deletion of single asset files
- Exclude encrypted notes from the search index
This commit is contained in:
eezd committed 2026-10-17 14:24:55 +08:00
1 parent 75000fc571
commit 35d3138c7d
5 files changed
+329 -32

No files matched your search

+105
View File
@@ -152,3 +152,108 @@ func TestTreeHidesMetaAndAssets(t *testing.T) {
t.Fatalf("目录树不符: %v", paths)
}
}
func TestEncryptedNotes(t *testing.T) {
v := newVault(t)
if _, err := v.Write("a.md", []byte("明文"), Precondition{}); err != nil {
t.Fatal(err)
}
link, err := v.SaveAsset("a.md", "x.png", strings.NewReader("png"), 1<<20)
if err != nil {
t.Fatal(err)
}
// 同名的普通笔记与加密笔记不能同时存在(二者共用 a.assets)
if _, err := v.Write("a.md.enc", []byte("{}"), Precondition{}); !errors.Is(err, ErrExists) {
t.Fatal("同名加密笔记不应能直接创建:", err)
}
// 转换校验原笔记版本,写入新笔记并永久删除原笔记
if _, err := v.Convert("a.md", "a.md.enc", []byte(`{"v":1}`), Precondition{IfMatch: "stale"}); !isConflict(err) {
t.Fatal("版本不符时应拒绝转换:", err)
}
if _, err := v.Convert("a.md", "b.md.enc", []byte(`{}`), Precondition{}); !errors.Is(err, ErrInvalidPath) {
t.Fatal("只能转换为同名笔记:", err)
}
if _, err := v.Convert("a.md", "a.md.enc", []byte(`{"v":1}`), Precondition{IfMatch: Rev([]byte("明文"))}); err != nil {
t.Fatal(err)
}
if _, _, err := v.Read("a.md"); !errors.Is(err, ErrNotFound) {
t.Fatal("原笔记应被删除")
}
if items, _ := v.TrashList(); len(items) != 0 {
t.Fatal("转换前的明文不应进入回收站")
}
// 加密笔记的图片统一为 .enc,与普通笔记共用图片目录
encLink, err := v.SaveAsset("a.md.enc", "y.png", strings.NewReader("密文"), 1<<20)
if err != nil || !strings.HasPrefix(encLink, "a.assets/") || !strings.HasSuffix(encLink, ".enc") {
t.Fatal("加密图片链接不符:", encLink, err)
}
// 清理转换前的明文图片:只允许删除 .assets 目录中的文件
if err := v.DeleteAsset(link); err != nil {
t.Fatal(err)
}
if err := v.DeleteAsset("a.md.enc"); !errors.Is(err, ErrInvalidPath) {
t.Fatal("不应能经此删除笔记")
}
// 改名:图片目录随之移动,密文不改写;不能借移动转换类型,也不能与同名普通笔记冲突
if err := v.Move("a.md.enc", "c.md"); !errors.Is(err, ErrInvalidPath) {
t.Fatal("移动不能改变加密类型:", err)
}
if _, err := v.Write("c.md", []byte("x"), Precondition{}); err != nil {
t.Fatal(err)
}
if err := v.Move("a.md.enc", "c.md.enc"); !errors.Is(err, ErrExists) {
t.Fatal("不能移动到与普通笔记同名:", err)
}
if err := v.Move("a.md.enc", "d.md.enc"); err != nil {
t.Fatal(err)
}
if data, _, _ := v.Read("d.md.enc"); string(data) != `{"v":1}` {
t.Fatal("加密笔记内容不应被改写:", string(data))
}
if _, err := os.Stat(filepath.Join(v.Root(), strings.Replace(encLink, "a.assets", "d.assets", 1))); err != nil {
t.Fatal("图片目录未随加密笔记移动:", err)
}
entries, _ := v.Tree()
var names []string
for _, e := range entries {
names = append(names, e.Path)
}
if strings.Join(names, ",") != "c.md,d.md.enc" {
t.Fatal("目录树应包含加密笔记:", names)
}
}
func TestKeyring(t *testing.T) {
v := newVault(t)
if _, _, err := v.ReadKeyring(); !errors.Is(err, ErrNotFound) {
t.Fatal("尚未设置时应返回 ErrNotFound")
}
if _, err := v.WriteKeyring([]byte("not json"), Precondition{IfNoneMatch: true}); !errors.Is(err, ErrInvalidPath) {
t.Fatal("应拒绝非 JSON")
}
rev, err := v.WriteKeyring([]byte(`{"v":1}`), Precondition{IfNoneMatch: true})
if err != nil {
t.Fatal(err)
}
if _, err := v.WriteKeyring([]byte(`{"v":2}`), Precondition{IfNoneMatch: true}); !errors.Is(err, ErrExists) {
t.Fatal("已设置时不能再次初始化")
}
if _, err := v.WriteKeyring([]byte(`{"v":2}`), Precondition{IfMatch: "stale"}); !isConflict(err) {
t.Fatal("版本不符时应拒绝")
}
if _, err := v.WriteKeyring([]byte(`{"v":2}`), Precondition{IfMatch: rev}); err != nil {
t.Fatal(err)
}
if data, _, _ := v.ReadKeyring(); string(data) != `{"v":2}` {
t.Fatal("密钥环内容不符:", string(data))
}
// 密钥环在隐藏目录中,不经文件接口暴露
if _, _, err := v.Read(".rawmark/keyring.json"); err == nil {
t.Fatal("不应能经文件接口读取密钥环")
}
}
func isConflict(err error) bool {
var c *ConflictError
return errors.As(err, &c)
}