Files
dsh-web/internal/api/files_test.go
T

543 lines
15 KiB
Go

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)
}
}