fix(maintenance): distinguish invalid backups from server failures
This commit is contained in:
1 parent
ae99b884c6
commit
a8cec9901f
7 files changed
+252
-73
No files matched your search
@@ -96,7 +96,7 @@ go test -tags dev ./web
|
|||||||
|
|
||||||
登录/初始化返回 `{token,expiresAt}`。浏览器将 `rms_` 会话 Token 保存于 localStorage `rawmark.token`,所有受保护请求带 `Authorization: Bearer <token>`;服务端只存哈希、固定三十天有效,不滑动续期。`GET /auth/me` 返回真实权限及到期时间,`POST /auth/logout` 服务端撤销会话后返回 204;注销失败保留本地身份并明确报错。401 清除当前身份及动态路由,403 保留身份;旧请求或排队写入不能覆盖或注销新会话。localStorage 属于同源脚本可读的凭据,管理员安装的前端插件仍为全权限插件,不能承诺隔离。
|
登录/初始化返回 `{token,expiresAt}`。浏览器将 `rms_` 会话 Token 保存于 localStorage `rawmark.token`,所有受保护请求带 `Authorization: Bearer <token>`;服务端只存哈希、固定三十天有效,不滑动续期。`GET /auth/me` 返回真实权限及到期时间,`POST /auth/logout` 服务端撤销会话后返回 204;注销失败保留本地身份并明确报错。401 清除当前身份及动态路由,403 保留身份;旧请求或排队写入不能覆盖或注销新会话。localStorage 属于同源脚本可读的凭据,管理员安装的前端插件仍为全权限插件,不能承诺隔离。
|
||||||
|
|
||||||
普通 JSON 响应统一为 `{code,data,message}`,code=0 成功,message 表示成功或明确失败原因;错误保留 400/401/403/404/409/412/429/500 等 HTTP 状态。通用错误码为 HTTP 状态乘 100,40001 验证码错误/已使用、40002 验证码过期、40003 当前密码错误、40101 密码错误、41200 版本冲突;冲突版本位于 `data.rev`。文件原文、下载、WebSocket、204 和 OpenAPI 文档不包装。旧 JSON 客户端必须更新解包方式,不提供双格式兼容。
|
普通 JSON 响应统一为 `{code,data,message}`,code=0 成功,message 表示成功或明确失败原因;错误保留 400/401/403/404/409/412/429/500/503 等 HTTP 状态。维护接口中,无效输入或备份内容返回 400,维护占用或磁盘空间不足返回 503,文件系统/数据库内部故障返回 500。通用错误码为 HTTP 状态乘 100,40001 验证码错误/已使用、40002 验证码过期、40003 当前密码错误、40101 密码错误、41200 版本冲突;冲突版本位于 `data.rev`。文件原文、下载、WebSocket、204 和 OpenAPI 文档不包装。旧 JSON 客户端必须更新解包方式,不提供双格式兼容。
|
||||||
|
|
||||||
`rm_` API 令牌无需重新创建,read/write 权限不变;令牌不能管理账户、令牌、维护或资源票据。只读令牌仍可 POST 导出。第三方脚本只需保留 Bearer 请求头并按新 JSON 协议解包,文件/导出原始字节不变;WebSocket 客户端可继续以 Bearer 请求头连接 `/api/v1/events`。
|
`rm_` API 令牌无需重新创建,read/write 权限不变;令牌不能管理账户、令牌、维护或资源票据。只读令牌仍可 POST 导出。第三方脚本只需保留 Bearer 请求头并按新 JSON 协议解包,文件/导出原始字节不变;WebSocket 客户端可继续以 Bearer 请求头连接 `/api/v1/events`。
|
||||||
|
|
||||||
|
|||||||
+37
-20
@@ -13,6 +13,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"rawmark/internal/backup"
|
"rawmark/internal/backup"
|
||||||
@@ -53,9 +54,13 @@ func (s *Server) guardAPI(next http.Handler) http.Handler {
|
|||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var errMaintenanceBusy = errors.New("其他维护操作正在执行")
|
||||||
|
var errMaintenanceInput = errors.New("维护请求无效")
|
||||||
|
|
||||||
func (s *Server) maintain(operation string, fn func() error) error {
|
func (s *Server) maintain(operation string, fn func() error) error {
|
||||||
if !s.maintenanceMu.TryLock() {
|
if !s.maintenanceMu.TryLock() {
|
||||||
return fmt.Errorf("其他维护操作正在执行")
|
return errMaintenanceBusy
|
||||||
}
|
}
|
||||||
defer s.maintenanceMu.Unlock()
|
defer s.maintenanceMu.Unlock()
|
||||||
s.maintenanceBusy.Store(true)
|
s.maintenanceBusy.Store(true)
|
||||||
@@ -82,8 +87,20 @@ func (s *Server) maintenanceInfo(w http.ResponseWriter, r *http.Request) {
|
|||||||
defer s.maintenanceStatusMu.Unlock()
|
defer s.maintenanceStatusMu.Unlock()
|
||||||
writeJSON(w, 200, s.maintenanceState)
|
writeJSON(w, 200, s.maintenanceState)
|
||||||
}
|
}
|
||||||
func (s *Server) maintenanceError(w http.ResponseWriter, err error) {
|
func (s *Server) maintenanceError(w http.ResponseWriter, r *http.Request, err error) {
|
||||||
writeError(w, 400, "操作失败:"+err.Error())
|
var pathError *os.PathError
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, errMaintenanceBusy):
|
||||||
|
writeError(w, 503, err.Error())
|
||||||
|
case errors.Is(err, syscall.ENOSPC):
|
||||||
|
writeError(w, 503, "磁盘空间不足")
|
||||||
|
case errors.As(err, &pathError) && !errors.Is(pathError, os.ErrNotExist) && !errors.Is(pathError, syscall.ENOTDIR) && !errors.Is(pathError, syscall.EISDIR):
|
||||||
|
internalError(w, r, err)
|
||||||
|
case errors.Is(err, errMaintenanceInput), errors.Is(err, backup.ErrInvalidArchive):
|
||||||
|
writeError(w, 400, "操作失败:"+err.Error())
|
||||||
|
default:
|
||||||
|
internalError(w, r, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
func (s *Server) tempDir() (string, error) {
|
func (s *Server) tempDir() (string, error) {
|
||||||
dir := filepath.Join(s.DataDir, "temp")
|
dir := filepath.Join(s.DataDir, "temp")
|
||||||
@@ -207,7 +224,7 @@ func (s *Server) exportBackup(w http.ResponseWriter, r *http.Request) {
|
|||||||
return sendDownload(w, r, name, "rawmark-"+kind+"-"+time.Now().Format("20060102-150405")+".zip")
|
return sendDownload(w, r, name, "rawmark-"+kind+"-"+time.Now().Format("20060102-150405")+".zip")
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.maintenanceError(w, err)
|
s.maintenanceError(w, r, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
func (s *Server) exportNotes(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) exportNotes(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -229,46 +246,46 @@ func (s *Server) exportNotes(w http.ResponseWriter, r *http.Request) {
|
|||||||
return sendDownload(w, r, file.Name(), "rawmark-notes-"+time.Now().Format("20060102-150405")+".zip")
|
return sendDownload(w, r, file.Name(), "rawmark-notes-"+time.Now().Format("20060102-150405")+".zip")
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.maintenanceError(w, err)
|
s.maintenanceError(w, r, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func validatePreferences(values map[string]json.RawMessage) error {
|
func validatePreferences(values map[string]json.RawMessage) error {
|
||||||
for key, raw := range values {
|
for key, raw := range values {
|
||||||
if len(raw) > maxJSONBytes {
|
if len(raw) > maxJSONBytes {
|
||||||
return fmt.Errorf("单项设置超过 1 MiB")
|
return fmt.Errorf("%w:单项设置超过 1 MiB", errMaintenanceInput)
|
||||||
}
|
}
|
||||||
switch {
|
switch {
|
||||||
case key == settingsKey:
|
case key == settingsKey:
|
||||||
var settings map[string]json.RawMessage
|
var settings map[string]json.RawMessage
|
||||||
if err := json.Unmarshal(raw, &settings); err != nil || settings == nil {
|
if err := json.Unmarshal(raw, &settings); err != nil || settings == nil {
|
||||||
return fmt.Errorf("偏好设置必须为对象")
|
return fmt.Errorf("%w:偏好设置必须为对象", errMaintenanceInput)
|
||||||
}
|
}
|
||||||
if _, err := trashRetentionDays(settings); err != nil {
|
if _, err := trashRetentionDays(settings); err != nil {
|
||||||
return err
|
return fmt.Errorf("%w:%w", errMaintenanceInput, err)
|
||||||
}
|
}
|
||||||
if value, ok := settings["shortcuts"]; ok {
|
if value, ok := settings["shortcuts"]; ok {
|
||||||
var shortcuts map[string][]string
|
var shortcuts map[string][]string
|
||||||
if err := json.Unmarshal(value, &shortcuts); err != nil {
|
if err := json.Unmarshal(value, &shortcuts); err != nil {
|
||||||
return fmt.Errorf("快捷键格式无效")
|
return fmt.Errorf("%w:快捷键格式无效", errMaintenanceInput)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case key == pluginEnabledKey:
|
case key == pluginEnabledKey:
|
||||||
var ids map[string]bool
|
var ids map[string]bool
|
||||||
if err := json.Unmarshal(raw, &ids); err != nil {
|
if err := json.Unmarshal(raw, &ids); err != nil {
|
||||||
return err
|
return fmt.Errorf("%w:%w", errMaintenanceInput, err)
|
||||||
}
|
}
|
||||||
for id := range ids {
|
for id := range ids {
|
||||||
if !validPluginID(id) {
|
if !validPluginID(id) {
|
||||||
return fmt.Errorf("插件 ID 无效")
|
return fmt.Errorf("%w:插件 ID 无效", errMaintenanceInput)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case strings.HasPrefix(key, "plugin.data."):
|
case strings.HasPrefix(key, "plugin.data."):
|
||||||
if !validPluginID(strings.TrimPrefix(key, "plugin.data.")) || !json.Valid(raw) {
|
if !validPluginID(strings.TrimPrefix(key, "plugin.data.")) || !json.Valid(raw) {
|
||||||
return fmt.Errorf("插件数据无效")
|
return fmt.Errorf("%w:插件数据无效", errMaintenanceInput)
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("设置包包含不允许的设置项")
|
return fmt.Errorf("%w:设置包包含不允许的设置项", errMaintenanceInput)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -279,7 +296,7 @@ func readPreferences(stage string) (map[string]json.RawMessage, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if info.Size() > 32<<20 {
|
if info.Size() > 32<<20 {
|
||||||
return nil, fmt.Errorf("设置包设置内容超过 32 MiB")
|
return nil, fmt.Errorf("%w:设置包设置内容超过 32 MiB", errMaintenanceInput)
|
||||||
}
|
}
|
||||||
raw, err := os.ReadFile(filepath.Join(stage, "settings.json"))
|
raw, err := os.ReadFile(filepath.Join(stage, "settings.json"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -287,7 +304,7 @@ func readPreferences(stage string) (map[string]json.RawMessage, error) {
|
|||||||
}
|
}
|
||||||
var values map[string]json.RawMessage
|
var values map[string]json.RawMessage
|
||||||
if err = json.Unmarshal(raw, &values); err != nil || values == nil {
|
if err = json.Unmarshal(raw, &values); err != nil || values == nil {
|
||||||
return nil, fmt.Errorf("设置包格式无效")
|
return nil, fmt.Errorf("%w:设置包格式无效", errMaintenanceInput)
|
||||||
}
|
}
|
||||||
if err = validatePreferences(values); err != nil {
|
if err = validatePreferences(values); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -314,7 +331,7 @@ func (s *Server) applySettings(w http.ResponseWriter, r *http.Request) {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if !meta.Validated || meta.Kind != "settings" {
|
if !meta.Validated || meta.Kind != "settings" {
|
||||||
return fmt.Errorf("请先校验设置包")
|
return fmt.Errorf("%w:请先校验设置包", errMaintenanceInput)
|
||||||
}
|
}
|
||||||
stage := filepath.Join(dir, "stage")
|
stage := filepath.Join(dir, "stage")
|
||||||
values, err := readPreferences(stage)
|
values, err := readPreferences(stage)
|
||||||
@@ -407,7 +424,7 @@ func (s *Server) applySettings(w http.ResponseWriter, r *http.Request) {
|
|||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.maintenanceError(w, err)
|
s.maintenanceError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.hub.Broadcast(events.Event{Type: "settings"}, events.Event{Type: "plugins"})
|
s.hub.Broadcast(events.Event{Type: "settings"}, events.Event{Type: "plugins"})
|
||||||
@@ -432,7 +449,7 @@ func (s *Server) resetSettings(w http.ResponseWriter, r *http.Request) {
|
|||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.maintenanceError(w, err)
|
s.maintenanceError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.hub.Broadcast(events.Event{Type: "settings"}, events.Event{Type: "plugins"})
|
s.hub.Broadcast(events.Event{Type: "settings"}, events.Event{Type: "plugins"})
|
||||||
@@ -451,7 +468,7 @@ func (s *Server) maintainIndex(w http.ResponseWriter, r *http.Request) {
|
|||||||
return s.index.Optimize(r.Context())
|
return s.index.Optimize(r.Context())
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.maintenanceError(w, err)
|
s.maintenanceError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.hub.Broadcast(events.Event{Type: "indexed"})
|
s.hub.Broadcast(events.Event{Type: "indexed"})
|
||||||
@@ -518,6 +535,6 @@ func (s *Server) exportLogs(w http.ResponseWriter, r *http.Request) {
|
|||||||
return sendDownload(w, r, file, "rawmark-system-logs.zip")
|
return sendDownload(w, r, file, "rawmark-system-logs.zip")
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.maintenanceError(w, err)
|
s.maintenanceError(w, r, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,13 +4,16 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"syscall"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"rawmark/internal/backup"
|
"rawmark/internal/backup"
|
||||||
@@ -61,7 +64,7 @@ func exportedPackage(t *testing.T, f *fixture, kind string) []byte {
|
|||||||
}
|
}
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
func stagedPackage(t *testing.T, f *fixture, kind string, data []byte) string {
|
func uploadedPackage(t *testing.T, f *fixture, kind string, data []byte) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
res := f.do(f.admin, "POST", "/api/v1/maintenance/uploads", `{"kind":"`+kind+`","size":`+strconv.Itoa(len(data))+`}`, nil)
|
res := f.do(f.admin, "POST", "/api/v1/maintenance/uploads", `{"kind":"`+kind+`","size":`+strconv.Itoa(len(data))+`}`, nil)
|
||||||
if res.StatusCode != 201 {
|
if res.StatusCode != 201 {
|
||||||
@@ -82,7 +85,12 @@ func stagedPackage(t *testing.T, f *fixture, kind string, data []byte) string {
|
|||||||
}
|
}
|
||||||
offset = next
|
offset = next
|
||||||
}
|
}
|
||||||
res = f.do(f.admin, "POST", "/api/v1/maintenance/uploads/"+id+"/validate", "", nil)
|
return id
|
||||||
|
}
|
||||||
|
func stagedPackage(t *testing.T, f *fixture, kind string, data []byte) string {
|
||||||
|
t.Helper()
|
||||||
|
id := uploadedPackage(t, f, kind, data)
|
||||||
|
res := f.do(f.admin, "POST", "/api/v1/maintenance/uploads/"+id+"/validate", "", nil)
|
||||||
if res.StatusCode != 200 {
|
if res.StatusCode != 200 {
|
||||||
body, _ := io.ReadAll(res.Body)
|
body, _ := io.ReadAll(res.Body)
|
||||||
t.Fatalf("校验失败: %d %s", res.StatusCode, body)
|
t.Fatalf("校验失败: %d %s", res.StatusCode, body)
|
||||||
@@ -277,8 +285,8 @@ func TestSettingsPackageDatabaseFailureRollsBack(t *testing.T) {
|
|||||||
}
|
}
|
||||||
id := stagedPackage(t, f, "settings", data)
|
id := stagedPackage(t, f, "settings", data)
|
||||||
res := f.do(f.admin, "POST", "/api/v1/maintenance/settings/import", `{"id":"`+id+`","confirmed":true}`, nil)
|
res := f.do(f.admin, "POST", "/api/v1/maintenance/settings/import", `{"id":"`+id+`","confirmed":true}`, nil)
|
||||||
if res.StatusCode == 200 {
|
if res.StatusCode != 500 {
|
||||||
t.Fatal("错误提交返回成功")
|
t.Fatalf("数据库提交故障应返回 500,实际 %d", res.StatusCode)
|
||||||
}
|
}
|
||||||
value, _, err := f.store.Setting(settingsKey)
|
value, _, err := f.store.Setting(settingsKey)
|
||||||
if err != nil || !strings.Contains(value, "18") {
|
if err != nil || !strings.Contains(value, "18") {
|
||||||
@@ -288,3 +296,81 @@ func TestSettingsPackageDatabaseFailureRollsBack(t *testing.T) {
|
|||||||
t.Fatal("成功回退后仍有待执行记录")
|
t.Fatal("成功回退后仍有待执行记录")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMaintenanceErrorsPreserveHTTPStatus(t *testing.T) {
|
||||||
|
t.Run("invalid archive", func(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
id := uploadedPackage(t, f, "settings", []byte("not a zip"))
|
||||||
|
res := f.do(f.admin, "POST", "/api/v1/maintenance/uploads/"+id+"/validate", "", nil)
|
||||||
|
defer res.Body.Close()
|
||||||
|
var body response
|
||||||
|
if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if res.StatusCode != 400 || body.Code != 40000 || !strings.Contains(body.Message, "备份内容无效") {
|
||||||
|
t.Fatalf("无效备份响应错误: %d %+v", res.StatusCode, body)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("invalid resource structure", func(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
dir := t.TempDir()
|
||||||
|
settings := filepath.Join(dir, "settings.json")
|
||||||
|
fonts := filepath.Join(dir, "fonts-file")
|
||||||
|
if err := os.WriteFile(settings, []byte("{}"), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(fonts, []byte("not a directory"), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
archive := filepath.Join(dir, "invalid-resources.zip")
|
||||||
|
if err := backup.Create(context.Background(), archive, "settings", []backup.Source{{Name: "settings.json", Path: settings}, {Name: "app/fonts", Path: fonts}}, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(archive)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
id := uploadedPackage(t, f, "settings", data)
|
||||||
|
res := f.do(f.admin, "POST", "/api/v1/maintenance/uploads/"+id+"/validate", "", nil)
|
||||||
|
defer res.Body.Close()
|
||||||
|
if res.StatusCode != 400 {
|
||||||
|
t.Fatalf("归档资源结构错误应返回 400,实际 %d", res.StatusCode)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("filesystem failure", func(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
if err := os.WriteFile(filepath.Join(f.server.DataDir, ".maintenance"), []byte("blocked"), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
res := f.do(f.admin, "POST", "/api/v1/maintenance/uploads", `{"kind":"settings","size":10}`, nil)
|
||||||
|
defer res.Body.Close()
|
||||||
|
var body response
|
||||||
|
if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if res.StatusCode != 500 || body.Code != 50000 || body.Message != "服务器内部错误" {
|
||||||
|
t.Fatalf("磁盘故障被当作输入错误: %d %+v", res.StatusCode, body)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("maintenance contention", func(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
f.server.maintenanceMu.Lock()
|
||||||
|
defer f.server.maintenanceMu.Unlock()
|
||||||
|
res := f.do(f.admin, "POST", "/api/v1/maintenance/uploads", `{"kind":"settings","size":10}`, nil)
|
||||||
|
defer res.Body.Close()
|
||||||
|
if res.StatusCode != 503 {
|
||||||
|
t.Fatalf("维护占用应返回 503,实际 %d", res.StatusCode)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("insufficient disk space", func(t *testing.T) {
|
||||||
|
err := enoughSpace(t.TempDir(), math.MaxInt64)
|
||||||
|
if !errors.Is(err, syscall.ENOSPC) {
|
||||||
|
t.Fatalf("磁盘容量检查未保留 ENOSPC: %v", err)
|
||||||
|
}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
(&Server{}).maintenanceError(w, httptest.NewRequest("POST", "/api/v1/maintenance/uploads", nil), err)
|
||||||
|
if w.Code != 503 {
|
||||||
|
t.Fatalf("磁盘空间不足应返回 503,实际 %d", w.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -18,6 +18,8 @@ import (
|
|||||||
|
|
||||||
"rawmark/internal/backup"
|
"rawmark/internal/backup"
|
||||||
"rawmark/internal/store"
|
"rawmark/internal/store"
|
||||||
|
|
||||||
|
"modernc.org/sqlite"
|
||||||
)
|
)
|
||||||
|
|
||||||
const maintenanceChunkBytes int64 = 8 << 20
|
const maintenanceChunkBytes int64 = 8 << 20
|
||||||
@@ -37,10 +39,13 @@ func uploadOwner(r *http.Request) string {
|
|||||||
func (s *Server) readUpload(r *http.Request, id string) (maintenanceUpload, string, error) {
|
func (s *Server) readUpload(r *http.Request, id string) (maintenanceUpload, string, error) {
|
||||||
var meta maintenanceUpload
|
var meta maintenanceUpload
|
||||||
if !uploadIDPattern.MatchString(id) {
|
if !uploadIDPattern.MatchString(id) {
|
||||||
return meta, "", fmt.Errorf("上传 ID 无效")
|
return meta, "", fmt.Errorf("%w:上传 ID 无效", errMaintenanceInput)
|
||||||
}
|
}
|
||||||
dir := filepath.Join(s.DataDir, ".maintenance", "uploads", id)
|
dir := filepath.Join(s.DataDir, ".maintenance", "uploads", id)
|
||||||
data, err := os.ReadFile(filepath.Join(dir, "upload.json"))
|
data, err := os.ReadFile(filepath.Join(dir, "upload.json"))
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return meta, "", fmt.Errorf("%w:上传不存在或已过期", errMaintenanceInput)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return meta, "", err
|
return meta, "", err
|
||||||
}
|
}
|
||||||
@@ -48,7 +53,7 @@ func (s *Server) readUpload(r *http.Request, id string) (maintenanceUpload, stri
|
|||||||
return meta, "", err
|
return meta, "", err
|
||||||
}
|
}
|
||||||
if meta.Owner != uploadOwner(r) {
|
if meta.Owner != uploadOwner(r) {
|
||||||
return meta, "", fmt.Errorf("上传不属于当前会话")
|
return meta, "", fmt.Errorf("%w:上传不属于当前会话", errMaintenanceInput)
|
||||||
}
|
}
|
||||||
return meta, dir, nil
|
return meta, dir, nil
|
||||||
}
|
}
|
||||||
@@ -65,7 +70,7 @@ func enoughSpace(dir string, size int64) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if size < 0 || uint64(size)+(32<<20) > stat.Bavail*uint64(stat.Bsize) {
|
if size < 0 || uint64(size)+(32<<20) > stat.Bavail*uint64(stat.Bsize) {
|
||||||
return fmt.Errorf("磁盘空间不足")
|
return fmt.Errorf("磁盘空间不足:%w", syscall.ENOSPC)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -107,7 +112,7 @@ func (s *Server) startMaintenanceUpload(w http.ResponseWriter, r *http.Request)
|
|||||||
return f.Close()
|
return f.Close()
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.maintenanceError(w, err)
|
s.maintenanceError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, 201, map[string]string{"id": id})
|
writeJSON(w, 201, map[string]string{"id": id})
|
||||||
@@ -126,7 +131,7 @@ func (s *Server) appendMaintenanceUpload(w http.ResponseWriter, r *http.Request)
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if meta.Validated {
|
if meta.Validated {
|
||||||
return fmt.Errorf("已校验的上传不可修改")
|
return fmt.Errorf("%w:已校验的上传不可修改", errMaintenanceInput)
|
||||||
}
|
}
|
||||||
f, err := os.OpenFile(filepath.Join(dir, "archive.zip"), os.O_WRONLY, 0600)
|
f, err := os.OpenFile(filepath.Join(dir, "archive.zip"), os.O_WRONLY, 0600)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -138,18 +143,22 @@ func (s *Server) appendMaintenanceUpload(w http.ResponseWriter, r *http.Request)
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if info.Size() != offset {
|
if info.Size() != offset {
|
||||||
return fmt.Errorf("上传偏移不匹配,请重新上传")
|
return fmt.Errorf("%w:上传偏移不匹配,请重新上传", errMaintenanceInput)
|
||||||
}
|
}
|
||||||
limit := min(maintenanceChunkBytes, meta.Size-offset)
|
limit := min(maintenanceChunkBytes, meta.Size-offset)
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
return fmt.Errorf("上传大小超出清单")
|
return fmt.Errorf("%w:上传大小超出清单", errMaintenanceInput)
|
||||||
}
|
}
|
||||||
data, err := io.ReadAll(http.MaxBytesReader(w, r.Body, limit))
|
data, err := io.ReadAll(http.MaxBytesReader(w, r.Body, limit))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
var tooLarge *http.MaxBytesError
|
||||||
|
if errors.As(err, &tooLarge) {
|
||||||
|
return fmt.Errorf("%w:%w", errMaintenanceInput, err)
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if len(data) == 0 {
|
if len(data) == 0 {
|
||||||
return fmt.Errorf("上传内容为空")
|
return fmt.Errorf("%w:上传内容为空", errMaintenanceInput)
|
||||||
}
|
}
|
||||||
if err = enoughSpace(dir, int64(len(data))); err != nil {
|
if err = enoughSpace(dir, int64(len(data))); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -165,11 +174,20 @@ func (s *Server) appendMaintenanceUpload(w http.ResponseWriter, r *http.Request)
|
|||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.maintenanceError(w, err)
|
s.maintenanceError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, 200, map[string]int64{"offset": next})
|
writeJSON(w, 200, map[string]int64{"offset": next})
|
||||||
}
|
}
|
||||||
|
func backupResourceError(err error) error {
|
||||||
|
var pathError *os.PathError
|
||||||
|
// 缺失文件或文件/目录类型错误来自归档内容;其他磁盘故障仍由服务端处理。
|
||||||
|
if errors.As(err, &pathError) && !errors.Is(pathError, os.ErrNotExist) && !errors.Is(pathError, syscall.ENOTDIR) && !errors.Is(pathError, syscall.EISDIR) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%w:%w", backup.ErrInvalidArchive, err)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) validateResources(stage string) error {
|
func (s *Server) validateResources(stage string) error {
|
||||||
temp := &Server{FontsDir: filepath.Join(stage, "app", "fonts"), ThemesDir: filepath.Join(stage, "app", "themes"), PluginsDir: filepath.Join(stage, "app", "plugins")}
|
temp := &Server{FontsDir: filepath.Join(stage, "app", "fonts"), ThemesDir: filepath.Join(stage, "app", "themes"), PluginsDir: filepath.Join(stage, "app", "plugins")}
|
||||||
for _, kind := range []string{"fonts", "themes", "plugins"} {
|
for _, kind := range []string{"fonts", "themes", "plugins"} {
|
||||||
@@ -178,27 +196,27 @@ func (s *Server) validateResources(stage string) error {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return backupResourceError(err)
|
||||||
}
|
}
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
if !entry.IsDir() {
|
if !entry.IsDir() {
|
||||||
return fmt.Errorf("资源目录内包含非目录条目")
|
return fmt.Errorf("%w:资源目录内包含非目录条目", errMaintenanceInput)
|
||||||
}
|
}
|
||||||
switch kind {
|
switch kind {
|
||||||
case "fonts":
|
case "fonts":
|
||||||
dir, _, err := temp.openFont(entry.Name())
|
dir, _, err := temp.openFont(entry.Name())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return backupResourceError(err)
|
||||||
}
|
}
|
||||||
dir.Close()
|
dir.Close()
|
||||||
case "themes":
|
case "themes":
|
||||||
if _, _, err := temp.readTheme(entry.Name()); err != nil {
|
if _, _, err := temp.readTheme(entry.Name()); err != nil {
|
||||||
return err
|
return backupResourceError(err)
|
||||||
}
|
}
|
||||||
case "plugins":
|
case "plugins":
|
||||||
dir, _, err := temp.openPlugin(entry.Name())
|
dir, _, err := temp.openPlugin(entry.Name())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return backupResourceError(err)
|
||||||
}
|
}
|
||||||
dir.Close()
|
dir.Close()
|
||||||
}
|
}
|
||||||
@@ -219,7 +237,7 @@ func (s *Server) validateMaintenanceUpload(w http.ResponseWriter, r *http.Reques
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if info.Size() != meta.Size {
|
if info.Size() != meta.Size {
|
||||||
return fmt.Errorf("上传尚未完成")
|
return fmt.Errorf("%w:上传尚未完成", errMaintenanceInput)
|
||||||
}
|
}
|
||||||
meta.Validated = false
|
meta.Validated = false
|
||||||
if err = saveUpload(dir, meta); err != nil {
|
if err = saveUpload(dir, meta); err != nil {
|
||||||
@@ -237,12 +255,12 @@ func (s *Server) validateMaintenanceUpload(w http.ResponseWriter, r *http.Reques
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if manifest.Kind != meta.Kind {
|
if manifest.Kind != meta.Kind {
|
||||||
return fmt.Errorf("备份类型与所选操作不符")
|
return fmt.Errorf("%w:备份类型与所选操作不符", errMaintenanceInput)
|
||||||
}
|
}
|
||||||
for _, entry := range manifest.Entries {
|
for _, entry := range manifest.Entries {
|
||||||
bytes += entry.Size
|
bytes += entry.Size
|
||||||
if strings.HasPrefix(entry.Path, "vault/") && backup.VaultExcluded(s.DataDir, s.vault.Root(), strings.TrimPrefix(entry.Path, "vault/")) {
|
if strings.HasPrefix(entry.Path, "vault/") && backup.VaultExcluded(s.DataDir, s.vault.Root(), strings.TrimPrefix(entry.Path, "vault/")) {
|
||||||
return fmt.Errorf("笔记库内容与应用目录冲突")
|
return fmt.Errorf("%w:笔记库内容与应用目录冲突", errMaintenanceInput)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err = enoughSpace(dir, bytes*2); err != nil {
|
if err = enoughSpace(dir, bytes*2); err != nil {
|
||||||
@@ -263,6 +281,11 @@ func (s *Server) validateMaintenanceUpload(w http.ResponseWriter, r *http.Reques
|
|||||||
prefs, err = st.Preferences()
|
prefs, err = st.Preferences()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
err = validatePreferences(prefs)
|
err = validatePreferences(prefs)
|
||||||
|
} else {
|
||||||
|
var databaseError *sqlite.Error
|
||||||
|
if !errors.As(err, &databaseError) {
|
||||||
|
err = fmt.Errorf("%w:%w", backup.ErrInvalidArchive, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
err = errors.Join(err, st.Close())
|
err = errors.Join(err, st.Close())
|
||||||
}
|
}
|
||||||
@@ -275,7 +298,7 @@ func (s *Server) validateMaintenanceUpload(w http.ResponseWriter, r *http.Reques
|
|||||||
return saveUpload(dir, meta)
|
return saveUpload(dir, meta)
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.maintenanceError(w, err)
|
s.maintenanceError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, 200, map[string]any{"kind": manifest.Kind, "createdAt": manifest.CreatedAt, "entries": len(manifest.Entries), "size": bytes})
|
writeJSON(w, 200, map[string]any{"kind": manifest.Kind, "createdAt": manifest.CreatedAt, "entries": len(manifest.Entries), "size": bytes})
|
||||||
@@ -289,7 +312,7 @@ func (s *Server) deleteMaintenanceUpload(w http.ResponseWriter, r *http.Request)
|
|||||||
return os.RemoveAll(dir)
|
return os.RemoveAll(dir)
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.maintenanceError(w, err)
|
s.maintenanceError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, 200, map[string]bool{"ok": true})
|
writeJSON(w, 200, map[string]bool{"ok": true})
|
||||||
@@ -316,7 +339,7 @@ func (s *Server) restoreInstance(w http.ResponseWriter, r *http.Request) {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if !meta.Validated || meta.Kind != "full" {
|
if !meta.Validated || meta.Kind != "full" {
|
||||||
return fmt.Errorf("请先校验完整服务备份")
|
return fmt.Errorf("%w:请先校验完整服务备份", errMaintenanceInput)
|
||||||
}
|
}
|
||||||
file, err := s.saveRollback(r.Context(), "full")
|
file, err := s.saveRollback(r.Context(), "full")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -329,7 +352,7 @@ func (s *Server) restoreInstance(w http.ResponseWriter, r *http.Request) {
|
|||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.maintenanceError(w, err)
|
s.maintenanceError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, 202, map[string]bool{"restarting": true})
|
writeJSON(w, 202, map[string]bool{"restarting": true})
|
||||||
@@ -421,7 +444,7 @@ func (s *Server) clearTemp(w http.ResponseWriter, r *http.Request) {
|
|||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.maintenanceError(w, err)
|
s.maintenanceError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, 200, result)
|
writeJSON(w, 200, result)
|
||||||
|
|||||||
+36
-18
@@ -3,6 +3,7 @@ package backup
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"archive/zip"
|
"archive/zip"
|
||||||
|
"compress/flate"
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
@@ -22,6 +23,9 @@ const MaxArchiveBytes int64 = 2 << 30
|
|||||||
const MaxExpandedBytes int64 = 8 << 30
|
const MaxExpandedBytes int64 = 8 << 30
|
||||||
const MaxEntries = 100000
|
const MaxEntries = 100000
|
||||||
|
|
||||||
|
// ErrInvalidArchive 表示归档内容不满足备份契约,不包含磁盘或文件系统故障。
|
||||||
|
var ErrInvalidArchive = errors.New("备份内容无效")
|
||||||
|
|
||||||
type Entry struct {
|
type Entry struct {
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
Size int64 `json:"size"`
|
Size int64 `json:"size"`
|
||||||
@@ -182,44 +186,54 @@ func Create(ctx context.Context, dest, kind string, sources []Source, skip func(
|
|||||||
func Extract(ctx context.Context, archive, dest string) (Manifest, error) {
|
func Extract(ctx context.Context, archive, dest string) (Manifest, error) {
|
||||||
var manifest Manifest
|
var manifest Manifest
|
||||||
z, err := zip.OpenReader(archive)
|
z, err := zip.OpenReader(archive)
|
||||||
|
if errors.Is(err, zip.ErrInsecurePath) {
|
||||||
|
z.Close()
|
||||||
|
return manifest, fmt.Errorf("%w:%w", ErrInvalidArchive, err)
|
||||||
|
}
|
||||||
|
if errors.Is(err, zip.ErrFormat) {
|
||||||
|
return manifest, fmt.Errorf("%w:%w", ErrInvalidArchive, err)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return manifest, err
|
return manifest, err
|
||||||
}
|
}
|
||||||
defer z.Close()
|
defer z.Close()
|
||||||
if len(z.File) > MaxEntries+1 {
|
if len(z.File) > MaxEntries+1 {
|
||||||
return manifest, fmt.Errorf("备份文件数超过上限")
|
return manifest, fmt.Errorf("%w:备份文件数超过上限", ErrInvalidArchive)
|
||||||
}
|
}
|
||||||
entries := map[string]*zip.File{}
|
entries := map[string]*zip.File{}
|
||||||
for _, f := range z.File {
|
for _, f := range z.File {
|
||||||
name := strings.TrimSuffix(f.Name, "/")
|
name := strings.TrimSuffix(f.Name, "/")
|
||||||
if !validPath(name) || entries[name] != nil {
|
if !validPath(name) || entries[name] != nil {
|
||||||
return manifest, fmt.Errorf("归档路径无效或重复")
|
return manifest, fmt.Errorf("%w:归档路径无效或重复", ErrInvalidArchive)
|
||||||
}
|
}
|
||||||
if !f.Mode().IsRegular() && !f.Mode().IsDir() {
|
if !f.Mode().IsRegular() && !f.Mode().IsDir() {
|
||||||
return manifest, fmt.Errorf("归档不允许符号链接或特殊文件")
|
return manifest, fmt.Errorf("%w:归档不允许符号链接或特殊文件", ErrInvalidArchive)
|
||||||
}
|
}
|
||||||
entries[name] = f
|
entries[name] = f
|
||||||
}
|
}
|
||||||
m := entries["manifest.json"]
|
m := entries["manifest.json"]
|
||||||
if m == nil || m.UncompressedSize64 > 32<<20 {
|
if m == nil || m.UncompressedSize64 > 32<<20 {
|
||||||
return manifest, fmt.Errorf("缺少有效备份清单")
|
return manifest, fmt.Errorf("%w:缺少有效备份清单", ErrInvalidArchive)
|
||||||
}
|
}
|
||||||
r, err := m.Open()
|
r, err := m.Open()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return manifest, err
|
return manifest, fmt.Errorf("%w:%w", ErrInvalidArchive, err)
|
||||||
}
|
}
|
||||||
err = json.NewDecoder(io.LimitReader(r, 32<<20)).Decode(&manifest)
|
err = json.NewDecoder(io.LimitReader(r, 32<<20)).Decode(&manifest)
|
||||||
|
if err != nil {
|
||||||
|
err = fmt.Errorf("%w:%w", ErrInvalidArchive, err)
|
||||||
|
}
|
||||||
err = errors.Join(err, r.Close())
|
err = errors.Join(err, r.Close())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return manifest, err
|
return manifest, err
|
||||||
}
|
}
|
||||||
if manifest.Format != 1 || (manifest.Kind != "full" && manifest.Kind != "settings") || len(manifest.Entries) != len(entries)-1 {
|
if manifest.Format != 1 || (manifest.Kind != "full" && manifest.Kind != "settings") || len(manifest.Entries) != len(entries)-1 {
|
||||||
return manifest, fmt.Errorf("备份版本、类型或清单无效")
|
return manifest, fmt.Errorf("%w:备份版本、类型或清单无效", ErrInvalidArchive)
|
||||||
}
|
}
|
||||||
var expanded int64
|
var expanded int64
|
||||||
for _, e := range manifest.Entries {
|
for _, e := range manifest.Entries {
|
||||||
if e.Size < 0 || e.Size > MaxExpandedBytes-expanded {
|
if e.Size < 0 || e.Size > MaxExpandedBytes-expanded {
|
||||||
return manifest, fmt.Errorf("备份展开大小超过 8 GiB")
|
return manifest, fmt.Errorf("%w:备份展开大小超过 8 GiB", ErrInvalidArchive)
|
||||||
}
|
}
|
||||||
expanded += e.Size
|
expanded += e.Size
|
||||||
}
|
}
|
||||||
@@ -228,7 +242,7 @@ func Extract(ctx context.Context, archive, dest string) (Manifest, error) {
|
|||||||
return manifest, err
|
return manifest, err
|
||||||
}
|
}
|
||||||
if uint64(expanded)+(32<<20) > disk.Bavail*uint64(disk.Bsize) {
|
if uint64(expanded)+(32<<20) > disk.Bavail*uint64(disk.Bsize) {
|
||||||
return manifest, fmt.Errorf("磁盘空间不足")
|
return manifest, fmt.Errorf("磁盘空间不足:%w", syscall.ENOSPC)
|
||||||
}
|
}
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
var total int64
|
var total int64
|
||||||
@@ -238,17 +252,17 @@ func Extract(ctx context.Context, archive, dest string) (Manifest, error) {
|
|||||||
}
|
}
|
||||||
f := entries[e.Path]
|
f := entries[e.Path]
|
||||||
if !validEntry(manifest.Kind, e.Path) || seen[e.Path] || f == nil || e.Size < 0 || e.Dir != f.FileInfo().IsDir() || uint64(e.Size) != f.UncompressedSize64 {
|
if !validEntry(manifest.Kind, e.Path) || seen[e.Path] || f == nil || e.Size < 0 || e.Dir != f.FileInfo().IsDir() || uint64(e.Size) != f.UncompressedSize64 {
|
||||||
return manifest, fmt.Errorf("备份清单与文件不符")
|
return manifest, fmt.Errorf("%w:备份清单与文件不符", ErrInvalidArchive)
|
||||||
}
|
}
|
||||||
seen[e.Path] = true
|
seen[e.Path] = true
|
||||||
total += e.Size
|
total += e.Size
|
||||||
if total > MaxExpandedBytes {
|
if total > MaxExpandedBytes {
|
||||||
return manifest, fmt.Errorf("备份展开大小超过 8 GiB")
|
return manifest, fmt.Errorf("%w:备份展开大小超过 8 GiB", ErrInvalidArchive)
|
||||||
}
|
}
|
||||||
target := filepath.Join(dest, filepath.FromSlash(e.Path))
|
target := filepath.Join(dest, filepath.FromSlash(e.Path))
|
||||||
if e.Dir {
|
if e.Dir {
|
||||||
if e.Size != 0 {
|
if e.Size != 0 {
|
||||||
return manifest, fmt.Errorf("目录大小无效")
|
return manifest, fmt.Errorf("%w:目录大小无效", ErrInvalidArchive)
|
||||||
}
|
}
|
||||||
if err := os.MkdirAll(target, 0700); err != nil {
|
if err := os.MkdirAll(target, 0700); err != nil {
|
||||||
return manifest, err
|
return manifest, err
|
||||||
@@ -256,14 +270,14 @@ func Extract(ctx context.Context, archive, dest string) (Manifest, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if len(e.SHA256) != 64 {
|
if len(e.SHA256) != 64 {
|
||||||
return manifest, fmt.Errorf("文件校验值无效")
|
return manifest, fmt.Errorf("%w:文件校验值无效", ErrInvalidArchive)
|
||||||
}
|
}
|
||||||
if err := os.MkdirAll(filepath.Dir(target), 0700); err != nil {
|
if err := os.MkdirAll(filepath.Dir(target), 0700); err != nil {
|
||||||
return manifest, err
|
return manifest, err
|
||||||
}
|
}
|
||||||
in, err := f.Open()
|
in, err := f.Open()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return manifest, err
|
return manifest, fmt.Errorf("%w:%w", ErrInvalidArchive, err)
|
||||||
}
|
}
|
||||||
out, err := os.OpenFile(target, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600)
|
out, err := os.OpenFile(target, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -272,23 +286,27 @@ func Extract(ctx context.Context, archive, dest string) (Manifest, error) {
|
|||||||
}
|
}
|
||||||
sum := sha256.New()
|
sum := sha256.New()
|
||||||
n, copyErr := io.Copy(io.MultiWriter(out, sum), io.LimitReader(in, e.Size+1))
|
n, copyErr := io.Copy(io.MultiWriter(out, sum), io.LimitReader(in, e.Size+1))
|
||||||
|
var corruptDeflate flate.CorruptInputError
|
||||||
|
if errors.Is(copyErr, zip.ErrChecksum) || errors.Is(copyErr, zip.ErrFormat) || errors.Is(copyErr, io.ErrUnexpectedEOF) || errors.As(copyErr, &corruptDeflate) {
|
||||||
|
copyErr = fmt.Errorf("%w:%w", ErrInvalidArchive, copyErr)
|
||||||
|
}
|
||||||
err = errors.Join(copyErr, in.Close(), out.Close())
|
err = errors.Join(copyErr, in.Close(), out.Close())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return manifest, err
|
return manifest, err
|
||||||
}
|
}
|
||||||
if n != e.Size || hex.EncodeToString(sum.Sum(nil)) != e.SHA256 {
|
if n != e.Size || hex.EncodeToString(sum.Sum(nil)) != e.SHA256 {
|
||||||
return manifest, fmt.Errorf("文件校验失败: %s", e.Path)
|
return manifest, fmt.Errorf("%w:文件校验失败: %s", ErrInvalidArchive, e.Path)
|
||||||
}
|
}
|
||||||
t := time.UnixMilli(e.Modified)
|
t := time.UnixMilli(e.Modified)
|
||||||
if err := os.Chtimes(target, t, t); err != nil {
|
if err := os.Chtimes(target, t, t); err != nil {
|
||||||
return manifest, err
|
return manifest, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if manifest.Kind == "full" && (!seen["app/rawmark.db"] || !seen["vault"]) {
|
if manifest.Kind == "full" && (!seen["app/rawmark.db"] || !seen["vault"] || entries["app/rawmark.db"].FileInfo().IsDir() || !entries["vault"].FileInfo().IsDir()) {
|
||||||
return manifest, fmt.Errorf("实例备份缺少数据库或笔记库")
|
return manifest, fmt.Errorf("%w:实例备份缺少数据库或笔记库", ErrInvalidArchive)
|
||||||
}
|
}
|
||||||
if manifest.Kind == "settings" && !seen["settings.json"] {
|
if manifest.Kind == "settings" && (!seen["settings.json"] || entries["settings.json"].FileInfo().IsDir()) {
|
||||||
return manifest, fmt.Errorf("设置备份缺少设置")
|
return manifest, fmt.Errorf("%w:设置备份缺少设置", ErrInvalidArchive)
|
||||||
}
|
}
|
||||||
return manifest, nil
|
return manifest, nil
|
||||||
}
|
}
|
||||||
@@ -116,7 +116,7 @@ func TestRecoveryCommitAndRollback(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
func TestArchiveRejectsTraversalDuplicateLinksAndCorruption(t *testing.T) {
|
func TestArchiveRejectsTraversalDuplicateLinksAndCorruption(t *testing.T) {
|
||||||
for _, mode := range []string{"traversal", "duplicate", "symlink", "checksum"} {
|
for _, mode := range []string{"traversal", "duplicate", "symlink", "checksum", "directory-settings"} {
|
||||||
t.Run(mode, func(t *testing.T) {
|
t.Run(mode, func(t *testing.T) {
|
||||||
file := filepath.Join(t.TempDir(), "invalid.zip")
|
file := filepath.Join(t.TempDir(), "invalid.zip")
|
||||||
f, _ := os.Create(file)
|
f, _ := os.Create(file)
|
||||||
@@ -138,15 +138,36 @@ func TestArchiveRejectsTraversalDuplicateLinksAndCorruption(t *testing.T) {
|
|||||||
out.Write([]byte("{}"))
|
out.Write([]byte("{}"))
|
||||||
manifest, _ := z.Create("manifest.json")
|
manifest, _ := z.Create("manifest.json")
|
||||||
io.WriteString(manifest, `{"format":1,"kind":"settings","entries":[{"path":"settings.json","size":2,"sha256":"`+strings.Repeat("0", 64)+`"}]}`)
|
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()
|
z.Close()
|
||||||
f.Close()
|
f.Close()
|
||||||
if _, err := Extract(context.Background(), file, t.TempDir()); err == nil {
|
if _, err := Extract(context.Background(), file, t.TempDir()); !errors.Is(err, ErrInvalidArchive) {
|
||||||
t.Fatal("无效归档被接受")
|
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) {
|
func TestSettingsInterruptedImportRestoresPreferencesAndResources(t *testing.T) {
|
||||||
data := t.TempDir()
|
data := t.TempDir()
|
||||||
database(t, filepath.Join(data, "rawmark.db"))
|
database(t, filepath.Join(data, "rawmark.db"))
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"rawmark/internal/store"
|
"rawmark/internal/store"
|
||||||
|
|
||||||
|
"modernc.org/sqlite"
|
||||||
|
sqlite3 "modernc.org/sqlite/lib"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RestoreRecord 必须在关闭 HTTP、文件监听、索引和数据库之后执行。
|
// RestoreRecord 必须在关闭 HTTP、文件监听、索引和数据库之后执行。
|
||||||
@@ -74,12 +77,15 @@ func CheckDatabase(file string) error {
|
|||||||
}
|
}
|
||||||
header := make([]byte, 16)
|
header := make([]byte, 16)
|
||||||
_, err = io.ReadFull(f, header)
|
_, err = io.ReadFull(f, header)
|
||||||
|
if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
|
||||||
|
err = fmt.Errorf("%w:%w", ErrInvalidArchive, err)
|
||||||
|
}
|
||||||
err = errors.Join(err, f.Close())
|
err = errors.Join(err, f.Close())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if string(header) != "SQLite format 3\x00" {
|
if string(header) != "SQLite format 3\x00" {
|
||||||
return fmt.Errorf("备份数据库格式无效")
|
return fmt.Errorf("%w:备份数据库格式无效", ErrInvalidArchive)
|
||||||
}
|
}
|
||||||
st, err := store.OpenExisting(file)
|
st, err := store.OpenExisting(file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -87,7 +93,15 @@ func CheckDatabase(file string) error {
|
|||||||
}
|
}
|
||||||
defer st.Close()
|
defer st.Close()
|
||||||
if err = st.CheckBackup(); err != nil {
|
if err = st.CheckBackup(); err != nil {
|
||||||
return err
|
var databaseError *sqlite.Error
|
||||||
|
if errors.As(err, &databaseError) {
|
||||||
|
switch databaseError.Code() & 0xff {
|
||||||
|
case sqlite3.SQLITE_ERROR, sqlite3.SQLITE_CORRUPT, sqlite3.SQLITE_NOTADB:
|
||||||
|
default:
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%w:%w", ErrInvalidArchive, err)
|
||||||
}
|
}
|
||||||
password, ok, err := st.Setting("auth.password")
|
password, ok, err := st.Setting("auth.password")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -95,15 +109,15 @@ func CheckDatabase(file string) error {
|
|||||||
}
|
}
|
||||||
parts := strings.Split(password, "$")
|
parts := strings.Split(password, "$")
|
||||||
if !ok || len(parts) != 6 || parts[1] != "argon2id" || parts[2] != "v=19" || parts[3] != "m=65536,t=3,p=4" {
|
if !ok || len(parts) != 6 || parts[1] != "argon2id" || parts[2] != "v=19" || parts[3] != "m=65536,t=3,p=4" {
|
||||||
return fmt.Errorf("备份密码格式不受支持")
|
return fmt.Errorf("%w:备份密码格式不受支持", ErrInvalidArchive)
|
||||||
}
|
}
|
||||||
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
|
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
|
||||||
if err != nil || len(salt) != 16 {
|
if err != nil || len(salt) != 16 {
|
||||||
return fmt.Errorf("备份密码盐无效")
|
return fmt.Errorf("%w:备份密码盐无效", ErrInvalidArchive)
|
||||||
}
|
}
|
||||||
key, err := base64.RawStdEncoding.DecodeString(parts[5])
|
key, err := base64.RawStdEncoding.DecodeString(parts[5])
|
||||||
if err != nil || len(key) != 32 {
|
if err != nil || len(key) != 32 {
|
||||||
return fmt.Errorf("备份密码校验值无效")
|
return fmt.Errorf("%w:备份密码校验值无效", ErrInvalidArchive)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user