237 lines
7.8 KiB
Go
237 lines
7.8 KiB
Go
package backup
|
|
|
|
import (
|
|
"archive/zip"
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
|
|
"rawmark/internal/auth"
|
|
"rawmark/internal/store"
|
|
)
|
|
|
|
func writeTest(t *testing.T, file, content string) {
|
|
t.Helper()
|
|
if err := os.MkdirAll(filepath.Dir(file), 0700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(file, []byte(content), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
func database(t *testing.T, file string) {
|
|
t.Helper()
|
|
st, err := store.Open(file)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err = auth.New(st).Setup("disposable-backup-password"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err = st.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
func fullArchive(t *testing.T, dir, content string) string {
|
|
t.Helper()
|
|
source := filepath.Join(dir, "source")
|
|
os.MkdirAll(source, 0700)
|
|
database(t, filepath.Join(source, "rawmark.db"))
|
|
writeTest(t, filepath.Join(source, "vault/note.md"), content)
|
|
archive := filepath.Join(dir, "backup.zip")
|
|
if err := Create(context.Background(), archive, "full", []Source{{"app/rawmark.db", filepath.Join(source, "rawmark.db")}, {"vault", filepath.Join(source, "vault")}}, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return archive
|
|
}
|
|
func TestRecoveryCommitAndRollback(t *testing.T) {
|
|
for _, mode := range []string{"prepared", "applying", "invalid-source", "verifying"} {
|
|
t.Run(mode, func(t *testing.T) {
|
|
data := t.TempDir()
|
|
vault := filepath.Join(data, "vault")
|
|
oldArchive := fullArchive(t, t.TempDir(), "old")
|
|
oldStage := t.TempDir()
|
|
if _, err := Extract(context.Background(), oldArchive, oldStage); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := CopyTree(filepath.Join(oldStage, "app"), data); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := CopyTree(filepath.Join(oldStage, "vault"), vault); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
os.MkdirAll(filepath.Join(data, "backups"), 0700)
|
|
saved := filepath.Join(data, "backups/old.zip")
|
|
if err := copyFile(oldArchive, saved, 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
upload := filepath.Join(data, ".maintenance/uploads/test/stage")
|
|
os.MkdirAll(upload, 0700)
|
|
newArchive := fullArchive(t, t.TempDir(), "new")
|
|
if _, err := Extract(context.Background(), newArchive, upload); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
state := mode
|
|
if mode == "invalid-source" {
|
|
state = "prepared"
|
|
writeTest(t, filepath.Join(upload, "app/rawmark.db"), "invalid SQLite")
|
|
}
|
|
if mode == "applying" || mode == "verifying" {
|
|
writeTest(t, filepath.Join(vault, "note.md"), "partial")
|
|
}
|
|
if err := WriteRecord(data, RestoreRecord{State: state, Stage: upload, Backup: saved}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
err := Recover(data, vault)
|
|
expected := "old"
|
|
if mode == "prepared" {
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if pending, _ := Pending(data); !pending {
|
|
t.Fatal("验证启动前不应提交")
|
|
}
|
|
if err = FinishRecovery(data); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
expected = "new"
|
|
} else if err == nil {
|
|
t.Fatal("回退应保留失败原因")
|
|
}
|
|
body, err := os.ReadFile(filepath.Join(vault, "note.md"))
|
|
if err != nil || string(body) != expected {
|
|
t.Fatalf("恢复内容错误: %q %v", body, err)
|
|
}
|
|
if pending, err := Pending(data); err != nil || pending {
|
|
t.Fatalf("回退或提交后仍有记录: %v", err)
|
|
}
|
|
if err = CheckDatabase(filepath.Join(data, "rawmark.db")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
func TestArchiveRejectsTraversalDuplicateLinksAndCorruption(t *testing.T) {
|
|
for _, mode := range []string{"traversal", "duplicate", "symlink", "checksum", "directory-settings"} {
|
|
t.Run(mode, func(t *testing.T) {
|
|
file := filepath.Join(t.TempDir(), "invalid.zip")
|
|
f, _ := os.Create(file)
|
|
z := zip.NewWriter(f)
|
|
switch mode {
|
|
case "traversal":
|
|
out, _ := z.Create("../outside")
|
|
out.Write([]byte("bad"))
|
|
case "duplicate":
|
|
z.Create("settings.json")
|
|
z.Create("settings.json")
|
|
case "symlink":
|
|
header := &zip.FileHeader{Name: "app/fonts/link"}
|
|
header.SetMode(os.ModeSymlink | 0777)
|
|
out, _ := z.CreateHeader(header)
|
|
out.Write([]byte("/etc/passwd"))
|
|
case "checksum":
|
|
out, _ := z.Create("settings.json")
|
|
out.Write([]byte("{}"))
|
|
manifest, _ := z.Create("manifest.json")
|
|
io.WriteString(manifest, `{"format":1,"kind":"settings","entries":[{"path":"settings.json","size":2,"sha256":"`+strings.Repeat("0", 64)+`"}]}`)
|
|
case "directory-settings":
|
|
z.Create("settings.json/")
|
|
manifest, _ := z.Create("manifest.json")
|
|
io.WriteString(manifest, `{"format":1,"kind":"settings","entries":[{"path":"settings.json","dir":true,"size":0}]}`)
|
|
}
|
|
z.Close()
|
|
f.Close()
|
|
if _, err := Extract(context.Background(), file, t.TempDir()); !errors.Is(err, ErrInvalidArchive) {
|
|
t.Fatalf("无效归档未归类为输入错误: %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestArchiveExtractionDistinguishesDiskFailure(t *testing.T) {
|
|
dir := t.TempDir()
|
|
source := filepath.Join(dir, "settings.json")
|
|
writeTest(t, source, "{}")
|
|
archive := filepath.Join(dir, "settings.zip")
|
|
if err := Create(context.Background(), archive, "settings", []Source{{"settings.json", source}}, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
blocked := filepath.Join(dir, "not-a-directory")
|
|
writeTest(t, blocked, "block extraction")
|
|
_, err := Extract(context.Background(), archive, blocked)
|
|
var pathError *os.PathError
|
|
if !errors.As(err, &pathError) || errors.Is(err, ErrInvalidArchive) {
|
|
t.Fatalf("解压磁盘故障被误判为归档内容无效: %v", err)
|
|
}
|
|
}
|
|
func TestSettingsInterruptedImportRestoresPreferencesAndResources(t *testing.T) {
|
|
data := t.TempDir()
|
|
database(t, filepath.Join(data, "rawmark.db"))
|
|
st, _ := store.OpenExisting(filepath.Join(data, "rawmark.db"))
|
|
st.SetSetting("app.settings", `{"fontSize":18}`)
|
|
source := t.TempDir()
|
|
writeTest(t, filepath.Join(source, "settings.json"), `{"app.settings":{"fontSize":18},"plugin.data.example":{"content":"old"}}`)
|
|
writeTest(t, filepath.Join(source, "fonts/original/font.woff2"), "old-font")
|
|
os.MkdirAll(filepath.Join(data, "backups"), 0700)
|
|
archive := filepath.Join(data, "backups/settings.zip")
|
|
if err := Create(context.Background(), archive, "settings", []Source{{"settings.json", filepath.Join(source, "settings.json")}, {"app/fonts", filepath.Join(source, "fonts")}}, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
st.SetSetting("app.settings", `{"fontSize":24}`)
|
|
st.Close()
|
|
writeTest(t, filepath.Join(data, "fonts/new/font.woff2"), "new-font")
|
|
stage := filepath.Join(data, ".maintenance/uploads/test/stage")
|
|
os.MkdirAll(stage, 0700)
|
|
if err := WriteRecord(data, RestoreRecord{State: "applying", Kind: "settings", Stage: stage, Backup: archive}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := Recover(data, filepath.Join(data, "vault")); err == nil {
|
|
t.Fatal("应报告中断并回退")
|
|
}
|
|
st, _ = store.OpenExisting(filepath.Join(data, "rawmark.db"))
|
|
defer st.Close()
|
|
value, _, err := st.Setting("app.settings")
|
|
if err != nil || value != `{"fontSize":18}` {
|
|
t.Fatal("偏好未回退")
|
|
}
|
|
if body, err := os.ReadFile(filepath.Join(data, "fonts/original/font.woff2")); err != nil || string(body) != "old-font" {
|
|
t.Fatal("资源未回退")
|
|
}
|
|
if _, err := os.Stat(filepath.Join(data, "fonts/new")); !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatal("残留新资源")
|
|
}
|
|
}
|
|
func TestLogRotationIsBounded(t *testing.T) {
|
|
dir := t.TempDir()
|
|
log, err := OpenLog(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer log.Close()
|
|
chunk := []byte(strings.Repeat("a", 6<<20))
|
|
for i := 0; i < 5; i++ {
|
|
if _, err = log.Write(chunk); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
dest := t.TempDir()
|
|
if err = log.Snapshot(dest); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
entries, _ := os.ReadDir(dir)
|
|
if len(entries) != 3 {
|
|
t.Fatalf("轮转文件数错误: %d", len(entries))
|
|
}
|
|
for _, entry := range entries {
|
|
info, _ := entry.Info()
|
|
if info.Size() > 10<<20 || (runtime.GOOS != "windows" && info.Mode().Perm() != 0600) {
|
|
t.Fatalf("日志未限量或权限不符: %+v", info)
|
|
}
|
|
}
|
|
}
|