feat: add safe backup management and theme preferences
Add snapshot usage and protected cleanup, preserve unsaved profile edits, avoid holding SQL connections during snapshot deletion, and fix download filenames. Include regression tests and API documentation.
This commit is contained in:
@@ -0,0 +1,303 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.misaka.ren/m1saka/dsh-web/internal/store"
|
||||
)
|
||||
|
||||
var snapshotIDPattern = regexp.MustCompile(`^(pre-restore-[a-f0-9]{32}|failed-restore-[a-f0-9]{32}|upgrade-[a-f0-9]{32}-snapshot|failed-home-[a-f0-9]{32})$`)
|
||||
|
||||
var errUnsafeManagementPath = errors.New("unsafe storage path")
|
||||
|
||||
func managementPathError(w http.ResponseWriter, err error) {
|
||||
if errors.Is(err, errUnsafeManagementPath) {
|
||||
fail(w, 400, "unsafe storage path")
|
||||
return
|
||||
}
|
||||
fail(w, 500, "storage unavailable")
|
||||
}
|
||||
|
||||
type snapshotInfo struct {
|
||||
ID string `json:"id"`
|
||||
Size int64 `json:"size"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Protected bool `json:"protected"`
|
||||
ProtectionReason string `json:"protection_reason"`
|
||||
SizeError string `json:"size_error,omitempty"`
|
||||
}
|
||||
|
||||
func managementRoot(base string, parts ...string) (*os.Root, error) {
|
||||
root, err := os.OpenRoot(base)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, part := range parts {
|
||||
if part == "" || part == "." || part == ".." || strings.ContainsAny(part, "/\\") {
|
||||
root.Close()
|
||||
return nil, errUnsafeManagementPath
|
||||
}
|
||||
st, err := root.Lstat(part)
|
||||
if err != nil {
|
||||
root.Close()
|
||||
return nil, err
|
||||
}
|
||||
if !st.IsDir() {
|
||||
root.Close()
|
||||
return nil, errUnsafeManagementPath
|
||||
}
|
||||
next, err := root.OpenRoot(part)
|
||||
root.Close()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
actual, err := next.Stat(".")
|
||||
if err != nil {
|
||||
next.Close()
|
||||
return nil, err
|
||||
}
|
||||
if !os.SameFile(st, actual) {
|
||||
next.Close()
|
||||
return nil, errUnsafeManagementPath
|
||||
}
|
||||
root = next
|
||||
}
|
||||
return root, nil
|
||||
}
|
||||
|
||||
func (a *API) backupProtection(i store.Instance) string {
|
||||
if i.Status == "error" {
|
||||
return "instance recovery required"
|
||||
}
|
||||
var exists, pending int
|
||||
if err := a.s.DB.QueryRow("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='upgrade_journal'").Scan(&exists); err != nil {
|
||||
return "recovery journal unavailable"
|
||||
}
|
||||
if exists != 0 {
|
||||
if err := a.s.DB.QueryRow("SELECT count(*) FROM upgrade_journal WHERE instance_id=? AND (phase IS NULL OR phase NOT IN ('complete','rolled_back'))", i.ID).Scan(&pending); err != nil {
|
||||
return "recovery journal unavailable"
|
||||
}
|
||||
if pending != 0 {
|
||||
return "instance recovery required"
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func snapshotBytes(ctx context.Context, root *os.Root, name string) (int64, error) {
|
||||
var total int64
|
||||
count := 0
|
||||
err := fs.WalkDir(root.FS(), name, func(p string, d fs.DirEntry, err error) error {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
count++
|
||||
if count > 100000 || strings.Count(p, "/") > 128 {
|
||||
return errors.New("tree limit")
|
||||
}
|
||||
st, err := d.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if st.Mode().IsRegular() {
|
||||
total += st.Size()
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return total, err
|
||||
}
|
||||
|
||||
func (a *API) backupManagement(w http.ResponseWriter, r *http.Request, i store.Instance, action string) {
|
||||
w, flush := deferJSONResponse(w)
|
||||
defer flush()
|
||||
if r.Method != http.MethodDelete && !(action == "snapshots" && r.Method == http.MethodGet) {
|
||||
fail(w, 405, "method not allowed")
|
||||
return
|
||||
}
|
||||
id := r.URL.Query().Get("id")
|
||||
if r.Method == http.MethodDelete && ((action == "snapshots" && !snapshotIDPattern.MatchString(id)) || (action == "backups" && !backupIDPattern.MatchString(id))) {
|
||||
fail(w, 400, "invalid ID")
|
||||
return
|
||||
}
|
||||
lock := a.instanceLocks.Acquire(i.ID)
|
||||
defer lock.Release()
|
||||
user := a.userLocks.Acquire(i.UID)
|
||||
defer user.Release()
|
||||
current, err := a.s.GetInstance(i.ID)
|
||||
if err != nil || current.UID != i.UID {
|
||||
fail(w, 404, "not found")
|
||||
return
|
||||
}
|
||||
i = current
|
||||
reason := a.backupProtection(i)
|
||||
if r.Method == http.MethodDelete && reason != "" {
|
||||
fail(w, 409, reason)
|
||||
return
|
||||
}
|
||||
parts := []string{"workspaces", i.UID, "instances", i.ID}
|
||||
if action == "backups" {
|
||||
parts = []string{"backups", i.UID, i.ID}
|
||||
}
|
||||
root, err := managementRoot(a.root, parts...)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
if action == "backups" {
|
||||
a.deleteBackup(w, i, nil, id)
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodGet {
|
||||
writeJSON(w, []snapshotInfo{})
|
||||
return
|
||||
}
|
||||
fail(w, 404, "not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
managementPathError(w, err)
|
||||
return
|
||||
}
|
||||
defer root.Close()
|
||||
if r.Method == http.MethodGet {
|
||||
f, err := root.Open(".")
|
||||
if err != nil {
|
||||
fail(w, 500, "snapshot listing failed")
|
||||
return
|
||||
}
|
||||
entries, err := f.ReadDir(-1)
|
||||
f.Close()
|
||||
if err != nil {
|
||||
fail(w, 500, "snapshot listing failed")
|
||||
return
|
||||
}
|
||||
out := []snapshotInfo{}
|
||||
for _, entry := range entries {
|
||||
if !snapshotIDPattern.MatchString(entry.Name()) || !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
st, err := entry.Info()
|
||||
if err != nil {
|
||||
fail(w, 500, "snapshot metadata unavailable")
|
||||
return
|
||||
}
|
||||
s := snapshotInfo{ID: entry.Name(), CreatedAt: st.ModTime(), Protected: reason != "", ProtectionReason: reason}
|
||||
s.Size, err = snapshotBytes(r.Context(), root, entry.Name())
|
||||
if err != nil {
|
||||
s.Size = -1
|
||||
s.SizeError = "snapshot size unavailable: " + err.Error()
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
writeJSON(w, out)
|
||||
return
|
||||
}
|
||||
if action == "backups" {
|
||||
a.deleteBackup(w, i, root, id)
|
||||
return
|
||||
}
|
||||
st, err := root.Lstat(id)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
fail(w, 404, "snapshot not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
managementPathError(w, err)
|
||||
return
|
||||
}
|
||||
if !st.IsDir() {
|
||||
fail(w, 400, "unsafe snapshot path")
|
||||
return
|
||||
}
|
||||
a.deleteSnapshot(w, i, id, func() error { return root.RemoveAll(id) })
|
||||
}
|
||||
|
||||
func (a *API) deleteSnapshot(w http.ResponseWriter, i store.Instance, id string, remove func() error) {
|
||||
audit := func(state string) error {
|
||||
_, err := a.s.DB.Exec("INSERT INTO audit(uid,action,target,detail,at) VALUES(?,?,?,?,?)", i.UID, "snapshot.delete."+state, i.ID, id, time.Now().Unix())
|
||||
return err
|
||||
}
|
||||
if err := audit("started"); err != nil {
|
||||
fail(w, 500, "audit unavailable")
|
||||
return
|
||||
}
|
||||
if err := remove(); err != nil {
|
||||
if err := audit("failed"); err != nil {
|
||||
fail(w, 500, "snapshot deletion failed; audit unavailable")
|
||||
return
|
||||
}
|
||||
fail(w, 500, "snapshot deletion failed")
|
||||
return
|
||||
}
|
||||
if err := audit("completed"); err != nil {
|
||||
fail(w, 500, "snapshot removed; completion audit unavailable")
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
func (a *API) deleteBackup(w http.ResponseWriter, i store.Instance, root *os.Root, id string) {
|
||||
name := id + ".dshbak"
|
||||
var st os.FileInfo
|
||||
statErr := error(os.ErrNotExist)
|
||||
if root != nil {
|
||||
st, statErr = root.Lstat(name)
|
||||
}
|
||||
if statErr != nil && !errors.Is(statErr, os.ErrNotExist) {
|
||||
fail(w, 500, "backup unavailable")
|
||||
return
|
||||
}
|
||||
if statErr == nil && !st.Mode().IsRegular() {
|
||||
fail(w, 400, "unsafe backup path")
|
||||
return
|
||||
}
|
||||
tx, err := a.s.DB.Begin()
|
||||
if err != nil {
|
||||
fail(w, 500, "backup index unavailable")
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var exists, indexed int
|
||||
err = tx.QueryRow("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='backups'").Scan(&exists)
|
||||
if err == nil && exists != 0 {
|
||||
var result sql.Result
|
||||
result, err = tx.Exec("DELETE FROM backups WHERE id=? AND uid=? AND instance_id=?", id, i.UID, i.ID)
|
||||
if err == nil {
|
||||
n, e := result.RowsAffected()
|
||||
err = e
|
||||
indexed = int(n)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
fail(w, 500, "backup index unavailable")
|
||||
return
|
||||
}
|
||||
if statErr != nil && indexed == 0 {
|
||||
fail(w, 404, "backup not found")
|
||||
return
|
||||
}
|
||||
if _, err = tx.Exec("INSERT INTO audit(uid,action,target,detail,at) VALUES(?,?,?,?,?)", i.UID, "backup.delete", i.ID, id, time.Now().Unix()); err != nil {
|
||||
fail(w, 500, "audit unavailable")
|
||||
return
|
||||
}
|
||||
if statErr == nil {
|
||||
if err = root.Remove(name); err != nil {
|
||||
fail(w, 500, "backup deletion failed")
|
||||
return
|
||||
}
|
||||
}
|
||||
// Filesystem and SQLite cannot commit atomically; a retry clears a stale index after a failed commit.
|
||||
if err = tx.Commit(); err != nil {
|
||||
fail(w, 500, "backup removed; index update failed, retry deletion")
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]bool{"ok": true})
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSnapshotManagement(t *testing.T) {
|
||||
a, i, home := configTestAPI(t, false)
|
||||
a.docker = nil // These operations must never inspect or stop Docker.
|
||||
parent := filepath.Dir(home)
|
||||
suffix := strings.Repeat("a", 32)
|
||||
names := []string{"pre-restore-" + suffix, "failed-restore-" + suffix, "upgrade-" + suffix + "-snapshot", "failed-home-" + suffix}
|
||||
for _, name := range append(append([]string{}, names...), "pre-restore-no", "upgrade-"+suffix, "other") {
|
||||
if err := os.Mkdir(filepath.Join(parent, name), 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
configWrite(t, filepath.Join(parent, name, "data"), "abc")
|
||||
}
|
||||
outside := t.TempDir()
|
||||
configWrite(t, filepath.Join(outside, "keep"), "outside")
|
||||
if err := os.Symlink(outside, filepath.Join(parent, names[0], "link")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Link(filepath.Join(parent, names[0], "data"), filepath.Join(parent, names[0], "hardlink")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w := configCall(a, i, "GET", "snapshots", "")
|
||||
var got []snapshotInfo
|
||||
if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &got) != nil || len(got) != 4 {
|
||||
t.Fatal(w.Code, w.Body.String())
|
||||
}
|
||||
for _, s := range got {
|
||||
want := int64(3)
|
||||
if s.ID == names[0] {
|
||||
want = 6
|
||||
}
|
||||
if s.Size != want || s.Protected || s.ProtectionReason != "" || s.SizeError != "" || s.CreatedAt.IsZero() {
|
||||
t.Fatalf("%+v", s)
|
||||
}
|
||||
}
|
||||
for _, name := range names {
|
||||
w = configCall(a, i, "DELETE", "snapshots?id="+name, "")
|
||||
if w.Code != 200 {
|
||||
t.Fatal(w.Code, w.Body.String())
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(parent, name)); !os.IsNotExist(err) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, p := range []string{home, filepath.Join(parent, "other"), filepath.Join(outside, "keep")} {
|
||||
if _, err := os.Stat(p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
var count int
|
||||
if err := a.s.DB.QueryRow("SELECT count(*) FROM audit WHERE action='snapshot.delete.completed' AND uid=? AND target=?", i.UID, i.ID).Scan(&count); err != nil || count != 4 {
|
||||
t.Fatal(count, err)
|
||||
}
|
||||
if w = configCall(a, i, "DELETE", "snapshots?id="+names[0], ""); w.Code != 404 {
|
||||
t.Fatal(w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagementInvalidPaths(t *testing.T) {
|
||||
a, i, home := configTestAPI(t, false)
|
||||
a.docker = nil
|
||||
for _, action := range []string{"snapshots", "backups"} {
|
||||
for _, id := range []string{"", "home", "..", "../home", "/home", strings.Repeat("A", 32), "pre-restore-" + strings.Repeat("a", 32) + "/../home"} {
|
||||
if w := configCall(a, i, "DELETE", action+"?id="+url.QueryEscape(id), ""); w.Code != 400 {
|
||||
t.Fatal(action, id, w.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
id := "failed-home-" + strings.Repeat("b", 32)
|
||||
outside := t.TempDir()
|
||||
configWrite(t, filepath.Join(outside, "keep"), "safe")
|
||||
if err := os.Symlink(outside, filepath.Join(filepath.Dir(home), id)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if w := configCall(a, i, "DELETE", "snapshots?id="+id, ""); w.Code != 400 {
|
||||
t.Fatal(w.Code, w.Body.String())
|
||||
}
|
||||
if w := configCall(a, i, "GET", "snapshots", ""); w.Code != 200 || strings.TrimSpace(w.Body.String()) != "[]" {
|
||||
t.Fatal(w.Code, w.Body.String())
|
||||
}
|
||||
if err := os.Rename(filepath.Dir(home), filepath.Dir(home)+"-saved"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outside, filepath.Dir(home)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, method := range []string{"GET", "DELETE"} {
|
||||
if w := configCall(a, i, method, "snapshots?id="+id, ""); w.Code != 400 {
|
||||
t.Fatal(w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(outside, "keep")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagementProtection(t *testing.T) {
|
||||
for _, state := range []string{"error", "pending", "journal-error", "complete", "rolled_back"} {
|
||||
t.Run(state, func(t *testing.T) {
|
||||
a, i, home := configTestAPI(t, false)
|
||||
id := "pre-restore-" + strings.Repeat("c", 32)
|
||||
if err := os.Mkdir(filepath.Join(filepath.Dir(home), id), 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var err error
|
||||
if state == "error" {
|
||||
_, err = a.s.DB.Exec("UPDATE instances SET status='error' WHERE id=?", i.ID)
|
||||
} else if state == "journal-error" {
|
||||
_, err = a.s.DB.Exec("CREATE TABLE upgrade_journal(broken TEXT)")
|
||||
} else {
|
||||
_, err = a.s.DB.Exec("CREATE TABLE upgrade_journal(instance_id TEXT,phase TEXT)")
|
||||
if err == nil {
|
||||
_, err = a.s.DB.Exec("INSERT INTO upgrade_journal VALUES(?,?)", i.ID, state)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
protected := state != "complete" && state != "rolled_back"
|
||||
w := configCall(a, i, "GET", "snapshots", "")
|
||||
var got []snapshotInfo
|
||||
if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &got) != nil || len(got) != 1 || got[0].Protected != protected || (got[0].ProtectionReason != "") != protected {
|
||||
t.Fatal(w.Code, w.Body.String())
|
||||
}
|
||||
w = configCall(a, i, "DELETE", "snapshots?id="+id, "")
|
||||
want := 200
|
||||
if protected {
|
||||
want = 409
|
||||
}
|
||||
if w.Code != want {
|
||||
t.Fatal(w.Code, w.Body.String())
|
||||
}
|
||||
if protected {
|
||||
w = configCall(a, i, "DELETE", "backups?id="+strings.Repeat("a", 32), "")
|
||||
if w.Code != 409 {
|
||||
t.Fatal(w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshotSizeError(t *testing.T) {
|
||||
a, i, home := configTestAPI(t, false)
|
||||
id := "failed-restore-" + strings.Repeat("d", 32)
|
||||
deep := filepath.Join(filepath.Dir(home), id)
|
||||
for n := 0; n < 130; n++ {
|
||||
deep = filepath.Join(deep, "d")
|
||||
}
|
||||
if err := os.MkdirAll(deep, 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w := configCall(a, i, "GET", "snapshots", "")
|
||||
var got []snapshotInfo
|
||||
if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &got) != nil || len(got) != 1 || got[0].Size != -1 || got[0].SizeError == "" {
|
||||
t.Fatal(w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackupDelete(t *testing.T) {
|
||||
for _, mode := range []string{"indexed", "legacy", "stale", "missing-parent", "index-error", "symlink"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
a, i, _ := configTestAPI(t, false)
|
||||
a.docker = nil
|
||||
id := strings.Repeat("e", 32)
|
||||
dir := filepath.Join(a.root, "backups", i.UID, i.ID)
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
file := filepath.Join(dir, id+".dshbak")
|
||||
if mode != "stale" && mode != "symlink" {
|
||||
configWrite(t, file, "encrypted")
|
||||
}
|
||||
if mode == "symlink" {
|
||||
if err := os.Symlink(t.TempDir(), file); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if mode == "indexed" || mode == "stale" || mode == "missing-parent" {
|
||||
if err := a.indexBackup(i, id, "manual", 2, 9); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if mode == "index-error" {
|
||||
if _, err := a.s.DB.Exec("CREATE TABLE backups(broken TEXT)"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if mode == "missing-parent" {
|
||||
if err := os.RemoveAll(filepath.Join(a.root, "backups")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
w := configCall(a, i, "DELETE", "backups?id="+id, "")
|
||||
want := 200
|
||||
if mode == "index-error" {
|
||||
want = 500
|
||||
}
|
||||
if mode == "symlink" {
|
||||
want = 400
|
||||
}
|
||||
if w.Code != want {
|
||||
t.Fatal(w.Code, w.Body.String())
|
||||
}
|
||||
if want != 200 {
|
||||
if _, err := os.Lstat(file); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if _, err := os.Stat(file); !os.IsNotExist(err) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if mode != "legacy" {
|
||||
var count int
|
||||
if err := a.s.DB.QueryRow("SELECT count(*) FROM backups WHERE id=?", id).Scan(&count); err != nil || count != 0 {
|
||||
t.Fatal(count, err)
|
||||
}
|
||||
}
|
||||
var count int
|
||||
if err := a.s.DB.QueryRow("SELECT count(*) FROM audit WHERE action='backup.delete' AND target=? AND detail=?", i.ID, id).Scan(&count); err != nil || count != 1 {
|
||||
t.Fatal(count, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagementOwnershipRecheck(t *testing.T) {
|
||||
a, i, _ := configTestAPI(t, false)
|
||||
if _, err := a.s.DB.Exec("UPDATE instances SET uid='someone-else' WHERE id=?", i.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, action := range []string{"snapshots", "snapshots?id=pre-restore-" + strings.Repeat("a", 32), "backups?id=" + strings.Repeat("a", 32)} {
|
||||
method := "DELETE"
|
||||
if action == "snapshots" {
|
||||
method = "GET"
|
||||
}
|
||||
if w := configCall(a, i, method, action, ""); w.Code != 404 {
|
||||
t.Fatal(w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagementAuditFailure(t *testing.T) {
|
||||
a, i, home := configTestAPI(t, false)
|
||||
id := strings.Repeat("f", 32)
|
||||
snapshot := filepath.Join(filepath.Dir(home), "pre-restore-"+id)
|
||||
if err := os.Mkdir(snapshot, 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir := filepath.Join(a.root, "backups", i.UID, i.ID)
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
file := filepath.Join(dir, id+".dshbak")
|
||||
configWrite(t, file, "encrypted")
|
||||
if err := a.indexBackup(i, id, "manual", 2, 9); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := a.s.DB.Exec("DROP TABLE audit"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, action := range []string{"snapshots?id=pre-restore-" + id, "backups?id=" + id} {
|
||||
if w := configCall(a, i, "DELETE", action, ""); w.Code != 500 {
|
||||
t.Fatal(w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
for _, p := range []string{snapshot, file} {
|
||||
if _, err := os.Stat(p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
var count int
|
||||
if err := a.s.DB.QueryRow("SELECT count(*) FROM backups WHERE id=?", id).Scan(&count); err != nil || count != 1 {
|
||||
t.Fatal(count, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshotScanCancelled(t *testing.T) {
|
||||
root, err := os.OpenRoot(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer root.Close()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if _, err := snapshotBytes(ctx, root, "."); err != context.Canceled {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshotDeletionReleasesSQLConnection(t *testing.T) {
|
||||
a, i, _ := configTestAPI(t, false)
|
||||
a.s.DB.SetMaxOpenConns(1)
|
||||
entered, release, done := make(chan struct{}), make(chan struct{}), make(chan struct{})
|
||||
w := httptest.NewRecorder()
|
||||
go func() {
|
||||
defer close(done)
|
||||
a.deleteSnapshot(w, i, "snapshot", func() error {
|
||||
close(entered)
|
||||
<-release
|
||||
return nil
|
||||
})
|
||||
}()
|
||||
defer func() { close(release); <-done }()
|
||||
select {
|
||||
case <-entered:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("removal did not start")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
var count int
|
||||
if err := a.s.DB.QueryRowContext(ctx, "SELECT count(*) FROM audit WHERE action='snapshot.delete.started'").Scan(&count); err != nil || count != 1 {
|
||||
t.Fatalf("SQL blocked during removal or initial audit not persisted: count=%d err=%v", count, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshotDeletionAuditOutcomes(t *testing.T) {
|
||||
for _, mode := range []string{"success", "remove-failed", "start-failed", "completion-audit-failed", "failure-audit-failed"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
a, i, _ := configTestAPI(t, false)
|
||||
if mode == "start-failed" {
|
||||
if _, err := a.s.DB.Exec("DROP TABLE audit"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
called := false
|
||||
a.deleteSnapshot(w, i, "snapshot", func() error {
|
||||
called = true
|
||||
if strings.Contains(mode, "audit-failed") {
|
||||
if _, err := a.s.DB.Exec(`CREATE TRIGGER reject_terminal BEFORE INSERT ON audit WHEN NEW.action != 'snapshot.delete.started' BEGIN SELECT RAISE(FAIL, 'audit unavailable'); END`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if mode == "remove-failed" || mode == "failure-audit-failed" {
|
||||
return os.ErrPermission
|
||||
}
|
||||
return nil
|
||||
})
|
||||
want := 500
|
||||
if mode == "success" {
|
||||
want = 200
|
||||
}
|
||||
if w.Code != want || called != (mode != "start-failed") {
|
||||
t.Fatal(w.Code, w.Body.String(), called)
|
||||
}
|
||||
if mode == "start-failed" {
|
||||
return
|
||||
}
|
||||
for _, state := range []string{"started", "completed", "failed"} {
|
||||
wantCount := 0
|
||||
if state == "started" || (state == "completed" && mode == "success") || (state == "failed" && mode == "remove-failed") {
|
||||
wantCount = 1
|
||||
}
|
||||
var count int
|
||||
if err := a.s.DB.QueryRow("SELECT count(*) FROM audit WHERE action=? AND uid=? AND target=? AND detail=?", "snapshot.delete."+state, i.UID, i.ID, "snapshot").Scan(&count); err != nil || count != wantCount {
|
||||
t.Fatal(state, count, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagementPathErrorClassification(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
err error
|
||||
want int
|
||||
}{
|
||||
{"unsafe", errUnsafeManagementPath, 400},
|
||||
{"wrapped-unsafe", fmt.Errorf("path: %w", errUnsafeManagementPath), 400},
|
||||
{"permission", &os.PathError{Op: "lstat", Path: "snapshot", Err: os.ErrPermission}, 500},
|
||||
{"closed", os.ErrClosed, 500},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
managementPathError(w, tc.err)
|
||||
if w.Code != tc.want {
|
||||
t.Fatal(w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
a, i, _ := configTestAPI(t, false)
|
||||
base := t.TempDir()
|
||||
configWrite(t, filepath.Join(base, "file"), "not a directory")
|
||||
a.root = filepath.Join(base, "file", "child")
|
||||
if w := configCall(a, i, "GET", "snapshots", ""); w.Code != 500 {
|
||||
t.Fatal(w.Code, w.Body.String())
|
||||
}
|
||||
a.root = filepath.Join(base, "missing")
|
||||
if w := configCall(a, i, "GET", "snapshots", ""); w.Code != 200 || strings.TrimSpace(w.Body.String()) != "[]" {
|
||||
t.Fatal(w.Code, w.Body.String())
|
||||
}
|
||||
if w := configCall(a, i, "DELETE", "snapshots?id=pre-restore-"+strings.Repeat("a", 32), ""); w.Code != 404 {
|
||||
t.Fatal(w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,10 @@ var backupIDPattern = regexp.MustCompile("^[a-f0-9]{32}$")
|
||||
var configBodySlots = make(chan struct{}, 4)
|
||||
|
||||
func (a *API) config(w http.ResponseWriter, r *http.Request, i store.Instance, action string) {
|
||||
if action == "snapshots" || (action == "backups" && r.Method == http.MethodDelete) {
|
||||
a.backupManagement(w, r, i, action)
|
||||
return
|
||||
}
|
||||
controller := http.NewResponseController(w)
|
||||
if r.Method != http.MethodGet || action != "backups" || r.URL.Query().Get("download") == "" {
|
||||
var flush func()
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.misaka.ren/m1saka/dsh-web/internal/store"
|
||||
)
|
||||
|
||||
func TestWorkspaceDownloadFilename(t *testing.T) {
|
||||
a, sid := runtimeTestAPI(t, func(http.ResponseWriter, *http.Request) { t.Error("unexpected Docker call") })
|
||||
if err := a.s.AddInstance(store.Instance{ID: "one", UID: "owner"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
home := filepath.Join(a.root, "workspaces/owner/instances/one/home")
|
||||
checkFilename := func(t *testing.T, w *httptest.ResponseRecorder, want string) {
|
||||
t.Helper()
|
||||
header := w.Header().Get("Content-Disposition")
|
||||
mediaType, params, err := mime.ParseMediaType(header)
|
||||
if err != nil {
|
||||
t.Fatalf("parse Content-Disposition %q: %v", header, err)
|
||||
}
|
||||
if mediaType != "attachment" || params["filename"] != want {
|
||||
t.Fatalf("Content-Disposition = %q, want attachment with filename %q", header, want)
|
||||
}
|
||||
}
|
||||
const content = "0123456789\nunchanged 下载 content\x00\xff"
|
||||
for _, name := range []string{"report.txt", "中文文件.txt", "report with spaces.txt", `report";100%.txt`, "README"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
p := "parent/nested/" + name
|
||||
file := filepath.Join(home, filepath.FromSlash(p))
|
||||
if err := os.MkdirAll(filepath.Dir(file), 0750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(file, []byte(content), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, partial := range []bool{false, true} {
|
||||
r := runtimeRequest(http.MethodGet, "/api/ws/download?instance=one&path="+url.QueryEscape(p), "", sid)
|
||||
wantStatus, wantBody := http.StatusOK, content
|
||||
if partial {
|
||||
r.Header.Set("Range", "bytes=2-6")
|
||||
wantStatus, wantBody = http.StatusPartialContent, content[2:7]
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
a.workspace(w, r)
|
||||
if w.Code != wantStatus {
|
||||
t.Fatalf("partial=%v: status = %d, want %d: %s", partial, w.Code, wantStatus, w.Body.String())
|
||||
}
|
||||
checkFilename(t, w, name)
|
||||
if w.Body.String() != wantBody {
|
||||
t.Errorf("partial=%v: body = %q, want %q", partial, w.Body.String(), wantBody)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
t.Run("directory zip", func(t *testing.T) {
|
||||
const name = `资料 ";100%`
|
||||
p := "parent/" + name
|
||||
dir := filepath.Join(home, filepath.FromSlash(p))
|
||||
if err := os.MkdirAll(dir, 0750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "entry.txt"), []byte(content), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
a.workspace(w, runtimeRequest(http.MethodGet, "/api/ws/download?instance=one&path="+url.QueryEscape(p), "", sid))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
checkFilename(t, w, name+".zip")
|
||||
archive, err := zip.NewReader(bytes.NewReader(w.Body.Bytes()), int64(w.Body.Len()))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(archive.File) != 1 || archive.File[0].Name != "entry.txt" {
|
||||
t.Fatalf("unexpected archive entries: %v", archive.File)
|
||||
}
|
||||
r, err := archive.File[0].Open()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer r.Close()
|
||||
got, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != content {
|
||||
t.Errorf("archive body = %q, want %q", got, content)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -781,7 +781,7 @@ func (a *API) wsDownload(w http.ResponseWriter, r *http.Request, root *os.Root)
|
||||
fail(w, 400, "invalid file")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Disposition", "attachment")
|
||||
w.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": path.Base(p)}))
|
||||
http.ServeContent(w, r, path.Base(p), st.ModTime(), f)
|
||||
}
|
||||
|
||||
|
||||
@@ -340,7 +340,7 @@ func (a *API) instance(w http.ResponseWriter, r *http.Request) {
|
||||
case "profile-preview":
|
||||
a.profilePreview(w, r, i)
|
||||
return
|
||||
case "profile", "backups", "restore":
|
||||
case "profile", "backups", "restore", "snapshots":
|
||||
a.config(w, r, i, action)
|
||||
return
|
||||
case "upgrade":
|
||||
|
||||
Reference in New Issue
Block a user