package api import ( "archive/zip" "bytes" "context" "encoding/json" "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 TestWorkspaceText(t *testing.T) { a, sid := runtimeTestAPI(t, func(http.ResponseWriter, *http.Request) { t.Error("unexpected Docker call") }) for _, i := range []store.Instance{{ID: "one", UID: "owner"}, {ID: "foreign", UID: "other"}} { if err := a.s.AddInstance(i); err != nil { t.Fatal(err) } } dir := filepath.Join(a.root, "workspaces/owner/instances/one/home") if err := os.MkdirAll(dir, 0750); err != nil { t.Fatal(err) } file := filepath.Join(dir, "file") putFile := func(content string) { t.Helper() if err := os.WriteFile(file, []byte(content), 0751); err != nil { t.Fatal(err) } } putFile("original") if _, err := a.s.DB.Exec("UPDATE users SET quota=? WHERE id='owner'", 32<<20); err != nil { t.Fatal(err) } request := func(method, query, body, session string, want int) map[string]string { t.Helper() w := httptest.NewRecorder() a.workspace(w, runtimeRequest(method, "/api/ws/text?"+query, body, session)) if w.Code != want { t.Fatalf("%s %s: got %d want %d: %s", method, query, w.Code, want, w.Body.String()) } out := map[string]string{} if want == 200 { if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil { t.Fatal(err) } } return out } q := "instance=one&path=file" body := func(content, version string) string { b, _ := json.Marshal(map[string]string{"content": content, "version": version}) return string(b) } request("GET", q, "", "", 401) request("GET", "instance=foreign&path=file", "", sid, 404) request("GET", "path=file", "", sid, 410) for _, p := range []string{"../file", "..", "/etc/passwd", ".", "", "\x00", "./file", "a/../file", "a/./file", "a//file", "file/"} { for _, method := range []string{"GET", "PUT"} { request(method, "instance=one&path="+url.QueryEscape(p), "", sid, 400) } } for _, tc := range []struct{ exact, counterpart string }{ {"config.yaml ", "config.yaml"}, {" config.yaml", "config.yaml"}, {`a\b`, "a/b"}, } { for name, content := range map[string]string{tc.exact: "exact", tc.counterpart: "counterpart"} { if err := os.MkdirAll(filepath.Dir(filepath.Join(dir, name)), 0750); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0600); err != nil { t.Fatal(err) } } exactQuery := "instance=one&path=" + url.QueryEscape(tc.exact) got := request("GET", exactQuery, "", sid, 200) if got["content"] != "exact" { t.Fatalf("GET %q returned %q", tc.exact, got["content"]) } request("PUT", exactQuery, body("updated", got["version"]), sid, 200) if got := request("GET", exactQuery, "", sid, 200); got["content"] != "updated" { t.Fatalf("GET %q after PUT returned %q", tc.exact, got["content"]) } for name, want := range map[string]string{tc.exact: "updated", tc.counterpart: "counterpart"} { got, err := os.ReadFile(filepath.Join(dir, name)) if err != nil || string(got) != want { t.Fatalf("file %q: got %q, want %q: %v", name, got, want, err) } } } request("POST", q, "", sid, 405) v := request("GET", q, "", sid, 200)["version"] if v == "" { t.Fatal("missing version") } request("PUT", q, "{}", sid, 400) request("PUT", q, body("next", "stale"), sid, 409) request("PUT", q, body("next", ""), sid, 400) for _, content := range []string{"nul\x00", "del\x7f", "control\x01", "invalid\xff"} { putFile(content) request("GET", q, "", sid, 415) } putFile(strings.Repeat("x", maxTextBytes+1)) request("GET", q, "", sid, 413) putFile(strings.Repeat("x", maxTextBytes)) request("GET", q, "", sid, 200) putFile("original") v = request("GET", q, "", sid, 200)["version"] request("PUT", q, body(strings.Repeat("x", maxTextBytes+1), v), sid, 413) request("PUT", q, body("bad\x00", v), sid, 415) request("PUT", q, "{\"content\":\"\xff\",\"version\":\"v\"}", sid, 415) request("PUT", q, strings.Repeat(" ", 6*maxTextBytes+4097), sid, 413) if err := os.Symlink(file, filepath.Join(dir, "link")); err != nil { t.Fatal(err) } if err := os.Symlink(dir, filepath.Join(dir, "parent")); err != nil { t.Fatal(err) } for _, p := range []string{"link", "parent/file"} { request("GET", "instance=one&path="+p, "", sid, 404) request("PUT", "instance=one&path="+p, body("next", v), sid, 409) } if err := os.Link(file, filepath.Join(dir, "hard")); err != nil { t.Fatal(err) } request("GET", q, "", sid, 404) request("PUT", q, body("next", v), sid, 409) if err := os.Remove(filepath.Join(dir, "hard")); err != nil { t.Fatal(err) } if _, err := a.s.DB.Exec("UPDATE users SET quota=8 WHERE id='owner'"); err != nil { t.Fatal(err) } request("PUT", q, body("next", v), sid, 413) if _, err := a.s.DB.Exec("UPDATE users SET quota=? WHERE id='owner'", 32<<20); err != nil { t.Fatal(err) } before, err := os.Stat(file) if err != nil { t.Fatal(err) } next := "你好\tline\r\n" saved := request("PUT", q, body(next, v), sid, 200)["version"] got := request("GET", q, "", sid, 200) if got["content"] != next || got["version"] != saved || saved == v { t.Fatal(got, saved) } after, err := os.Stat(file) if err != nil { t.Fatal(err) } bs, as := before.Sys().(*syscall.Stat_t), after.Sys().(*syscall.Stat_t) if bs.Mode != as.Mode || bs.Uid != as.Uid || bs.Gid != as.Gid { t.Fatal("metadata changed") } request("PUT", q, body("stale save", v), sid, 409) results := make(chan int, 2) for range 2 { go func() { w := httptest.NewRecorder() a.workspace(w, runtimeRequest("PUT", "/api/ws/text?"+q, body("concurrent", saved), sid)) results <- w.Code }() } first, second := <-results, <-results if !(first == 200 && second == 409 || first == 409 && second == 200) { t.Fatalf("concurrent saves: %d %d", first, second) } saved = request("GET", q, "", sid, 200)["version"] putFile("external") request("PUT", q, body("stale save", saved), sid, 409) if err := os.Remove(file); err != nil { t.Fatal(err) } request("PUT", q, body("recreate", saved), sid, 409) request("GET", q, "", sid, 404) putFile("restored") if _, err := a.s.DB.Exec("UPDATE instances SET status='error' WHERE id='one'"); err != nil { t.Fatal(err) } request("PUT", q, body("blocked", saved), sid, 409) request("GET", q, "", sid, 200) entries, err := os.ReadDir(dir) if err != nil { t.Fatal(err) } for _, e := range entries { if strings.HasPrefix(e.Name(), ".text-") { t.Fatal("temporary file leaked") } } } 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 a.userLocks.held() { t.Fatal("user write lock held across write") } if a.instanceLocks.held() { t.Fatal("instance lock held across write") } 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} if err := s.CreateUser(u, "unused"); err != nil { t.Fatal(err) } if err := os.MkdirAll(filepath.Join(a.root, "workspaces/user/instances/one/home"), 0750); err != nil { t.Fatal(err) } root, e := a.openWorkspace(u, "one") 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/instances/one/home/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) } }