Initial commit: DSH Web Manager
This commit is contained in:
@@ -0,0 +1,542 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"git.misaka.ren/m1saka/dsh-web/internal/store"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestUploadExtensionPolicy(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, policy string
|
||||
want bool
|
||||
}{
|
||||
{"file.exe", "", true}, {"file.TXT", ".txt, .md", true},
|
||||
{"file.txt.exe", ".txt", false}, {"file", ".txt", false},
|
||||
{"file.exe", "*,txt", false}, {"file.json", ".txt,.json", true},
|
||||
} {
|
||||
if got := uploadExtensionAllowed(tc.name, tc.policy); got != tc.want {
|
||||
t.Errorf("%q %q: %v", tc.name, tc.policy, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func downloadTestRoot(t *testing.T) (string, *os.Root) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
root, err := os.OpenRoot(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { root.Close() })
|
||||
return dir, root
|
||||
}
|
||||
|
||||
func TestDirectoryDownloadZip(t *testing.T) {
|
||||
dir, root := downloadTestRoot(t)
|
||||
if err := os.MkdirAll(filepath.Join(dir, "docs/empty"), 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "docs/你好.txt"), []byte("hello zip"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, p := range []string{"docs", ".", "", "docs/empty"} {
|
||||
t.Run(p, func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
(&API{}).wsDownload(w, httptest.NewRequest("GET", "/api/ws/download?path="+url.QueryEscape(p), nil), root)
|
||||
if w.Code != 200 || w.Header().Get("Content-Type") != "application/zip" || !strings.Contains(w.Header().Get("Content-Disposition"), ".zip") {
|
||||
t.Fatal(w.Code, w.Header(), w.Body.String())
|
||||
}
|
||||
z, err := zip.NewReader(bytes.NewReader(w.Body.Bytes()), int64(w.Body.Len()))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := map[string]string{"empty/": "", "你好.txt": "hello zip"}
|
||||
if p == "." || p == "" {
|
||||
want = map[string]string{"docs/": "", "docs/empty/": "", "docs/你好.txt": "hello zip"}
|
||||
}
|
||||
if p == "docs/empty" {
|
||||
want = map[string]string{}
|
||||
}
|
||||
if len(z.File) != len(want) {
|
||||
t.Fatalf("entries %d want %d", len(z.File), len(want))
|
||||
}
|
||||
for _, f := range z.File {
|
||||
expect, ok := want[f.Name]
|
||||
if !ok {
|
||||
t.Fatal("unexpected entry", f.Name)
|
||||
}
|
||||
r, err := f.Open()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := io.ReadAll(r)
|
||||
r.Close()
|
||||
if err != nil || string(b) != expect {
|
||||
t.Fatal(f.Name, string(b), err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
r := httptest.NewRequest("GET", "/api/ws/download?path=docs/"+url.QueryEscape("你好.txt"), nil)
|
||||
r.Header.Set("Range", "bytes=1-3")
|
||||
w := httptest.NewRecorder()
|
||||
(&API{}).wsDownload(w, r, root)
|
||||
if w.Code != 206 || w.Body.String() != "ell" {
|
||||
t.Fatal(w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectoryDownloadRejectsUnsafeEntries(t *testing.T) {
|
||||
for _, kind := range []string{"symlink", "internal-link", "hardlink", "fifo", "backslash", "drive"} {
|
||||
t.Run(kind, func(t *testing.T) {
|
||||
dir, root := downloadTestRoot(t)
|
||||
if err := os.Mkdir(filepath.Join(dir, "docs"), 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outside := filepath.Join(t.TempDir(), "secret")
|
||||
if err := os.WriteFile(outside, []byte("secret"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
target := filepath.Join(dir, "docs/unsafe")
|
||||
var err error
|
||||
switch kind {
|
||||
case "symlink":
|
||||
err = os.Symlink(outside, target)
|
||||
case "internal-link":
|
||||
err = os.Symlink(".", target)
|
||||
case "hardlink":
|
||||
err = os.Link(outside, target)
|
||||
case "fifo":
|
||||
err = syscall.Mkfifo(target, 0600)
|
||||
case "backslash":
|
||||
err = os.WriteFile(filepath.Join(dir, `docs/..\secret`), nil, 0600)
|
||||
case "drive":
|
||||
err = os.WriteFile(filepath.Join(dir, "docs/C:secret"), nil, 0600)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
(&API{}).wsDownload(w, httptest.NewRequest("GET", "/api/ws/download?path=docs", nil), root)
|
||||
if w.Code != 400 || strings.Contains(w.Body.String(), "secret") {
|
||||
t.Fatal(w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
dir, root := downloadTestRoot(t)
|
||||
if err := os.Mkdir(filepath.Join(dir, "real"), 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "real/file"), []byte("secret"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink("real", filepath.Join(dir, "link")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, p := range []string{"../secret", "/etc/passwd", `..\secret`, "link", "link/file"} {
|
||||
w := httptest.NewRecorder()
|
||||
(&API{}).wsDownload(w, httptest.NewRequest("GET", "/api/ws/download?path="+url.QueryEscape(p), nil), root)
|
||||
if w.Code < 400 {
|
||||
t.Fatal("unsafe path accepted", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectoryDownloadLimits(t *testing.T) {
|
||||
for _, limit := range []string{"size", "depth", "count"} {
|
||||
t.Run(limit, func(t *testing.T) {
|
||||
dir, root := downloadTestRoot(t)
|
||||
switch limit {
|
||||
case "size":
|
||||
f, err := os.Create(filepath.Join(dir, "large"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = f.Truncate(maxZipBytes + 1)
|
||||
f.Close()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case "depth":
|
||||
if err := os.MkdirAll(filepath.Join(dir, strings.Repeat("d/", maxZipDepth+1)), 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case "count":
|
||||
for i := 0; i <= maxZipEntries; i++ {
|
||||
if err := os.WriteFile(filepath.Join(dir, fmt.Sprint(i)), nil, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
(&API{}).wsDownload(w, httptest.NewRequest("GET", "/api/ws/download?path=.", nil), root)
|
||||
if w.Code != 413 {
|
||||
t.Fatal(w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestZipManifestRechecksAndCancellation(t *testing.T) {
|
||||
for _, change := range []string{"symlink", "grow", "cancel"} {
|
||||
t.Run(change, func(t *testing.T) {
|
||||
dir, root := downloadTestRoot(t)
|
||||
p := filepath.Join(dir, "file")
|
||||
if err := os.WriteFile(p, []byte("data"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f, err := root.Open(".")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
es, err := zipManifest(ctx, f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
switch change {
|
||||
case "symlink":
|
||||
if err := os.Remove(p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink("/etc/passwd", p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case "grow":
|
||||
if err := os.WriteFile(p, []byte("more data"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case "cancel":
|
||||
cancel()
|
||||
}
|
||||
if err := streamZip(ctx, io.Discard, f, es); err == nil {
|
||||
t.Fatal("unsafe stream accepted")
|
||||
}
|
||||
if change == "cancel" {
|
||||
if _, err := zipManifest(ctx, f); !errors.Is(err, context.Canceled) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type failingZipWriter struct{}
|
||||
|
||||
func (failingZipWriter) Write([]byte) (int, error) { return 0, io.ErrClosedPipe }
|
||||
|
||||
func TestZipWriteFailureAndSizeBound(t *testing.T) {
|
||||
dir, root := downloadTestRoot(t)
|
||||
p := filepath.Join(dir, "file")
|
||||
if err := os.WriteFile(p, bytes.Repeat([]byte("x"), 65536), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f, err := root.Open(".")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
es, err := zipManifest(context.Background(), f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := streamZip(context.Background(), failingZipWriter{}, f, es); !errors.Is(err, io.ErrClosedPipe) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w := &growingZipWriter{path: p}
|
||||
if err := streamZip(context.Background(), w, f, es); err == nil {
|
||||
t.Fatal("concurrent growth accepted")
|
||||
}
|
||||
if w.Len() > 65536+1024 {
|
||||
t.Fatal("stream exceeded manifest bound", w.Len())
|
||||
}
|
||||
}
|
||||
|
||||
type growingZipWriter struct {
|
||||
bytes.Buffer
|
||||
path string
|
||||
}
|
||||
|
||||
func (w *growingZipWriter) Write(p []byte) (int, error) {
|
||||
if w.path != "" {
|
||||
f, err := os.OpenFile(w.path, os.O_WRONLY|os.O_APPEND, 0)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
_, err = f.Write([]byte("growth"))
|
||||
f.Close()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
w.path = ""
|
||||
}
|
||||
return w.Buffer.Write(p)
|
||||
}
|
||||
|
||||
type blockedZipWriter struct {
|
||||
*httptest.ResponseRecorder
|
||||
started chan struct{}
|
||||
release chan struct{}
|
||||
deadlines []time.Time
|
||||
}
|
||||
|
||||
func (w *blockedZipWriter) Write(p []byte) (int, error) {
|
||||
if w.started != nil {
|
||||
close(w.started)
|
||||
w.started = nil
|
||||
<-w.release
|
||||
}
|
||||
return w.ResponseRecorder.Write(p)
|
||||
}
|
||||
func (w *blockedZipWriter) SetWriteDeadline(d time.Time) error {
|
||||
w.deadlines = append(w.deadlines, d)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestZipTransferNoLocksAndConcurrencyLimit(t *testing.T) {
|
||||
_, root := downloadTestRoot(t)
|
||||
a := &API{}
|
||||
started := make(chan struct{})
|
||||
w := &blockedZipWriter{ResponseRecorder: httptest.NewRecorder(), started: started, release: make(chan struct{})}
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
a.wsDownload(w, httptest.NewRequest("GET", "/api/ws/download?path=.", nil), root)
|
||||
}()
|
||||
defer func() { close(w.release); <-done }()
|
||||
select {
|
||||
case <-started:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("download not started")
|
||||
}
|
||||
if !workspaceMu.TryLock() {
|
||||
t.Fatal("workspace lock held across write")
|
||||
}
|
||||
workspaceMu.Unlock()
|
||||
if !a.mu.TryLock() {
|
||||
t.Fatal("lifecycle lock held across write")
|
||||
}
|
||||
a.mu.Unlock()
|
||||
if len(w.deadlines) != 1 || w.deadlines[0].IsZero() {
|
||||
t.Fatal("missing deadline")
|
||||
}
|
||||
for i := 1; i < cap(zipSlots); i++ {
|
||||
zipSlots <- struct{}{}
|
||||
}
|
||||
defer func() {
|
||||
for i := 1; i < cap(zipSlots); i++ {
|
||||
<-zipSlots
|
||||
}
|
||||
}()
|
||||
busy := httptest.NewRecorder()
|
||||
a.wsDownload(busy, httptest.NewRequest("GET", "/api/ws/download?path=.", nil), root)
|
||||
if busy.Code != 429 {
|
||||
t.Fatal(busy.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenameNoReplace(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
root, err := os.OpenRoot(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer root.Close()
|
||||
for _, source := range []string{"source", ".upload-test"} {
|
||||
if err := os.WriteFile(filepath.Join(dir, source), []byte("new"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := root.Lstat("target"); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "target"), []byte("runtime"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := renameNoReplace(root, source, "target"); !errors.Is(err, os.ErrExist) {
|
||||
t.Fatalf("overwrite: %v", err)
|
||||
}
|
||||
b, err := root.ReadFile("target")
|
||||
if err != nil || string(b) != "runtime" {
|
||||
t.Fatalf("target changed: %q %v", b, err)
|
||||
}
|
||||
if err := root.Remove("target"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := renameNoReplace(root, source, "target"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := root.Remove("target"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := os.Mkdir(filepath.Join(dir, "real"), 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink("real", filepath.Join(dir, "link")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "source"), []byte("new"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, target := range []string{"link/target", "../target"} {
|
||||
if err := renameNoReplace(root, "source", target); err == nil {
|
||||
t.Fatalf("unsafe parent accepted: %s", target)
|
||||
}
|
||||
}
|
||||
if err := renameNoReplace(root, "link", "target"); err == nil {
|
||||
t.Fatal("symlink source accepted")
|
||||
}
|
||||
}
|
||||
|
||||
type stalledUpload struct {
|
||||
started chan struct{}
|
||||
release chan struct{}
|
||||
}
|
||||
|
||||
func (b *stalledUpload) Read([]byte) (int, error) {
|
||||
close(b.started)
|
||||
<-b.release
|
||||
return 0, io.EOF
|
||||
}
|
||||
func (b *stalledUpload) Close() error { return nil }
|
||||
|
||||
type deadlineRecorder struct {
|
||||
*httptest.ResponseRecorder
|
||||
deadlines []time.Time
|
||||
}
|
||||
|
||||
func (w *deadlineRecorder) SetReadDeadline(d time.Time) error {
|
||||
w.deadlines = append(w.deadlines, d)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestLegacyWorkspaceRemoved(t *testing.T) {
|
||||
a, sid := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) { t.Fatal("unexpected docker call") })
|
||||
for _, method := range []string{"GET", "POST", "DELETE"} {
|
||||
for _, endpoint := range []string{"tree", "download", "upload", "mkdir", "rename", "delete"} {
|
||||
w := httptest.NewRecorder()
|
||||
a.workspace(w, runtimeRequest(method, "/api/ws/"+endpoint, "", sid))
|
||||
if w.Code != http.StatusGone {
|
||||
t.Fatalf("%s %s: %d", method, endpoint, w.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceSafetyAndUpload(t *testing.T) {
|
||||
s, err := store.Open(filepath.Join(t.TempDir(), "test.sqlite"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
a := &API{s: s, root: t.TempDir()}
|
||||
u := store.User{ID: "user", Quota: 8}
|
||||
root, e := a.openWorkspace(u)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
defer root.Close()
|
||||
for _, p := range []string{"", ".", "a/..", "../other", "/tmp"} {
|
||||
w := httptest.NewRecorder()
|
||||
a.wsDelete(w, httptest.NewRequest("DELETE", "/api/ws/delete?path="+p, nil), root, u)
|
||||
if w.Code < 400 {
|
||||
t.Fatalf("deleted %q", p)
|
||||
}
|
||||
}
|
||||
outside := t.TempDir()
|
||||
os.WriteFile(filepath.Join(outside, "secret"), []byte("secret"), 0600)
|
||||
if e = os.Symlink(outside, filepath.Join(a.root, "workspaces/user/files/link")); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
a.wsDownload(w, httptest.NewRequest("GET", "/api/ws/download?path=link/secret", nil), root)
|
||||
if w.Code < 400 {
|
||||
t.Fatal("escaped root")
|
||||
}
|
||||
upload := func(name, content string) int {
|
||||
var b bytes.Buffer
|
||||
m := multipart.NewWriter(&b)
|
||||
m.WriteField("path", ".")
|
||||
f, _ := m.CreateFormFile("file", name)
|
||||
f.Write([]byte(content))
|
||||
m.Close()
|
||||
r := httptest.NewRequest("POST", "/api/ws/upload", &b)
|
||||
r.Header.Set("Content-Type", m.FormDataContentType())
|
||||
w := httptest.NewRecorder()
|
||||
a.wsUpload(w, r, root, u)
|
||||
return w.Code
|
||||
}
|
||||
if code := upload("file.txt", "hello"); code != 200 {
|
||||
t.Fatal(code)
|
||||
}
|
||||
if code := upload("big.txt", "12345"); code != 413 {
|
||||
t.Fatal("quota", code)
|
||||
}
|
||||
if code := upload("file.txt", "x"); code != 409 {
|
||||
t.Fatal("overwrite", code)
|
||||
}
|
||||
w = httptest.NewRecorder()
|
||||
a.wsDownload(w, httptest.NewRequest("GET", "/api/ws/download?path=file.txt", nil), root)
|
||||
if w.Code != 200 || w.Body.String() != "hello" {
|
||||
t.Fatal(w.Code, w.Body.String())
|
||||
}
|
||||
for _, op := range []struct{ action, body string }{
|
||||
{"mkdir", `{"path":"folder"}`},
|
||||
{"rename", `{"path":"folder","new_path":"renamed"}`},
|
||||
{"delete", ""},
|
||||
} {
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("POST", "/api/ws/"+op.action, strings.NewReader(op.body))
|
||||
switch op.action {
|
||||
case "mkdir":
|
||||
a.wsMkdir(w, r, root, u)
|
||||
case "rename":
|
||||
a.wsRename(w, r, root, u)
|
||||
case "delete":
|
||||
a.wsDelete(w, httptest.NewRequest("DELETE", "/api/ws/delete?path=renamed", nil), root, u)
|
||||
}
|
||||
if w.Code != 200 {
|
||||
t.Fatal(op.action, w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
rows, err := s.DB.Query("SELECT uid,action,target,detail FROM audit ORDER BY id")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
want := [][3]string{{"workspace.upload", "file.txt", ""}, {"workspace.mkdir", "folder", ""}, {"workspace.rename", "folder", "renamed"}, {"workspace.delete", "renamed", ""}}
|
||||
i := 0
|
||||
for rows.Next() {
|
||||
var uid, action, target, detail string
|
||||
if err := rows.Scan(&uid, &action, &target, &detail); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if i >= len(want) || uid != u.ID || [3]string{action, target, detail} != want[i] {
|
||||
t.Fatal("unexpected audit", uid, action, target, detail)
|
||||
}
|
||||
i++
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if i != len(want) {
|
||||
t.Fatal("missing audit entries", i)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user