mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-11-12 05:33:50 +08:00
425 lines
14 KiB
Go
425 lines
14 KiB
Go
package server
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"image"
|
|
"image/png"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// These tests need no database: the task registry and a temporary run directory
|
|
// exercise the same filesystem handlers used by the authenticated production mux.
|
|
func taskFilesFixture(t *testing.T) (*Server, string, func(string, string, string) *httptest.ResponseRecorder) {
|
|
t.Helper()
|
|
base := t.TempDir()
|
|
m := &Manager{dir: base, tasks: map[string]*Task{"313": {ID: "313"}}}
|
|
s := &Server{m: m}
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("GET /api/tasks/{id}/files", s.taskFilesList)
|
|
mux.HandleFunc("GET /api/tasks/{id}/files/all", s.taskFilesAll)
|
|
for _, action := range []string{"read", "download", "preview"} {
|
|
mux.HandleFunc("GET /api/tasks/{id}/files/"+action, s.taskFilesServe)
|
|
}
|
|
mux.HandleFunc("GET /api/tasks/{id}/files/archive", s.taskFilesArchive)
|
|
run := filepath.Join(base, "tasks", "313")
|
|
request := func(id, action, path string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
target := "/api/tasks/" + id + "/files"
|
|
if action != "" {
|
|
target += "/" + action
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
mux.ServeHTTP(rec, httptest.NewRequest("GET", target+"?path="+url.QueryEscape(path), nil))
|
|
return rec
|
|
}
|
|
return s, run, request
|
|
}
|
|
func taskFileWrite(t *testing.T, base, name string, data []byte) {
|
|
t.Helper()
|
|
p := filepath.Join(base, name)
|
|
if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(p, data, 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
func taskFileListingResult(t *testing.T, rec *httptest.ResponseRecorder) taskFileListing {
|
|
t.Helper()
|
|
if rec.Code != 200 {
|
|
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
var result taskFileListing
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return result
|
|
}
|
|
func TestTaskFilesValidationAndMissing(t *testing.T) {
|
|
_, run, get := taskFilesFixture(t)
|
|
for _, id := range []string{"abc", "-1", "0", "0313", "99999999999999999999", "314"} {
|
|
if r := get(id, "", ""); r.Code != 404 {
|
|
t.Errorf("%s status %d", id, r.Code)
|
|
}
|
|
}
|
|
for _, action := range []string{"", "all"} {
|
|
got := taskFileListingResult(t, get("313", action, ""))
|
|
if !got.Missing || got.Total != 0 || len(got.Entries) != 0 {
|
|
t.Fatalf("missing: %+v", got)
|
|
}
|
|
}
|
|
if r := get("313", "read", "missing"); r.Code != 404 {
|
|
t.Fatal(r.Code)
|
|
}
|
|
taskFileWrite(t, run, "ok.txt", []byte("safe"))
|
|
for _, path := range []string{"../secret", "a/../../secret", "/etc/passwd", "C:/secret", `..\secret`, "a//b", "a/../ok.txt", "a\x00b"} {
|
|
for _, action := range []string{"", "all", "read", "download", "preview", "archive"} {
|
|
if r := get("313", action, path); r.Code != 400 {
|
|
t.Errorf("%s %q status %d", action, path, r.Code)
|
|
}
|
|
}
|
|
}
|
|
for _, action := range []string{"", "read", "download", "archive"} {
|
|
if r := get("313", action, "missing"); r.Code != 404 {
|
|
t.Errorf("%s missing %d", action, r.Code)
|
|
}
|
|
}
|
|
if err := os.RemoveAll(run); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if r := get("313", "download", "ok.txt"); r.Code != 404 {
|
|
t.Fatal(r.Code)
|
|
}
|
|
}
|
|
func TestTaskFilesSymlinkIsolationAndZip(t *testing.T) {
|
|
s, run, get := taskFilesFixture(t)
|
|
taskFileWrite(t, run, "nested/hello.txt", []byte("hello"))
|
|
taskFileWrite(t, run, ".hidden", []byte("hidden"))
|
|
taskFileWrite(t, run, `..\unsafe.txt`, []byte("unsafe ZIP name"))
|
|
outside := t.TempDir()
|
|
taskFileWrite(t, outside, "secret.txt", []byte("SECRET"))
|
|
for name, target := range map[string]string{"escape.txt": filepath.Join(outside, "secret.txt"), "escape-dir": outside, "inside": "nested/hello.txt"} {
|
|
if err := os.Symlink(target, filepath.Join(run, name)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
for _, name := range []string{"escape.txt", "escape-dir/secret.txt"} {
|
|
for _, action := range []string{"read", "download", "preview", "archive"} {
|
|
if r := get("313", action, name); r.Code == 200 {
|
|
t.Errorf("escaped %s %s", action, name)
|
|
}
|
|
}
|
|
}
|
|
if r := get("313", "", "escape-dir"); r.Code == 200 {
|
|
t.Fatal("directory escape")
|
|
}
|
|
listing := taskFileListingResult(t, get("313", "", ""))
|
|
links := 0
|
|
for _, e := range listing.Entries {
|
|
if e.Symlink {
|
|
links++
|
|
}
|
|
}
|
|
if links != 3 {
|
|
t.Fatalf("links=%d", links)
|
|
}
|
|
if r := get("313", "read", "inside"); r.Code != 200 || !strings.Contains(r.Body.String(), "hello") {
|
|
t.Fatalf("internal link %d %s", r.Code, r.Body.String())
|
|
}
|
|
all := taskFileListingResult(t, get("313", "all", ""))
|
|
if all.Total != 3 || all.Size != 26 {
|
|
t.Fatalf("all: %+v", all)
|
|
}
|
|
for _, dir := range []string{"", "nested"} {
|
|
rec := get("313", "archive", dir)
|
|
if rec.Code != 200 || rec.Header().Get("Content-Type") != "application/zip" {
|
|
t.Fatal(rec.Code)
|
|
}
|
|
zr, err := zip.NewReader(bytes.NewReader(rec.Body.Bytes()), int64(rec.Body.Len()))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
found := false
|
|
for _, entry := range zr.File {
|
|
if strings.Contains(entry.Name, "escape") || entry.Name == "inside" || strings.Contains(entry.Name, "\\") {
|
|
t.Fatalf("zipped link %s", entry.Name)
|
|
}
|
|
if strings.HasSuffix(entry.Name, "hello.txt") {
|
|
f, err := entry.Open()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
data, err := io.ReadAll(f)
|
|
f.Close()
|
|
if err != nil || string(data) != "hello" {
|
|
t.Fatalf("ZIP contents %q %v", data, err)
|
|
}
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatal("missing file in zip")
|
|
}
|
|
}
|
|
// A symlink used as the task root must not be followed either.
|
|
s.m.tasks["314"] = &Task{ID: "314"}
|
|
if err := os.Symlink(outside, filepath.Join(filepath.Dir(run), "314")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if r := get("314", "read", "secret.txt"); r.Code == 200 {
|
|
t.Fatal("task root escaped")
|
|
}
|
|
}
|
|
func TestTaskFilesTextAndPreview(t *testing.T) {
|
|
_, run, get := taskFilesFixture(t)
|
|
cases := []struct {
|
|
name string
|
|
data []byte
|
|
binary, large bool
|
|
}{
|
|
{"empty", nil, false, false}, {"utf8", []byte("文本\n"), false, false},
|
|
{"nul", []byte{'a', 0}, true, false}, {"invalid", []byte{0xff}, true, false},
|
|
{"boundary", bytes.Repeat([]byte{'a'}, maxWorkspaceRead), false, false},
|
|
{"large", bytes.Repeat([]byte{'a'}, maxWorkspaceRead+1), true, true},
|
|
}
|
|
for _, tc := range cases {
|
|
taskFileWrite(t, run, tc.name, tc.data)
|
|
var got struct {
|
|
Binary bool `json:"binary"`
|
|
Large bool `json:"too_large"`
|
|
Content string `json:"content"`
|
|
}
|
|
rec := get("313", "read", tc.name)
|
|
if rec.Code != 200 {
|
|
t.Fatal(rec.Code)
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.Binary != tc.binary || got.Large != tc.large || (!tc.binary && got.Content != string(tc.data)) {
|
|
t.Fatalf("%s flags %v/%v", tc.name, got.Binary, got.Large)
|
|
}
|
|
}
|
|
// Content and extension must agree; HTML/SVG never render inline.
|
|
for _, name := range []string{"x.html", "x.svg", "spoof.png"} {
|
|
taskFileWrite(t, run, name, []byte("<html>unsafe</html>"))
|
|
if r := get("313", "preview", name); r.Code != 415 {
|
|
t.Fatalf("%s %d", name, r.Code)
|
|
}
|
|
}
|
|
var imageData bytes.Buffer
|
|
if err := png.Encode(&imageData, image.NewRGBA(image.Rect(0, 0, 2, 2))); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
taskFileWrite(t, run, "image.png", imageData.Bytes())
|
|
rec := get("313", "preview", "image.png")
|
|
if rec.Code != 200 || rec.Header().Get("Content-Type") != "image/png" || rec.Header().Get("X-Content-Type-Options") != "nosniff" {
|
|
t.Fatalf("preview %d %v", rec.Code, rec.Header())
|
|
}
|
|
rec = get("313", "download", "spoof.png")
|
|
if rec.Code != 200 || !strings.HasPrefix(rec.Header().Get("Content-Disposition"), "attachment;") || rec.Header().Get("Content-Type") != "application/octet-stream" {
|
|
t.Fatalf("download %d %v", rec.Code, rec.Header())
|
|
}
|
|
// Simulate growth after the stat: the shared reader must still cap allocation.
|
|
rec = httptest.NewRecorder()
|
|
writeTextFile(rec, "growing", bytes.NewReader(bytes.Repeat([]byte{'a'}, maxWorkspaceRead+100)), 1)
|
|
if !strings.Contains(rec.Body.String(), `"too_large":true`) {
|
|
t.Fatal("growing file not capped")
|
|
}
|
|
}
|
|
func TestTaskFilesRecentTruncation(t *testing.T) {
|
|
_, run, get := taskFilesFixture(t)
|
|
stamp := time.Unix(1700000000, 0)
|
|
for i := 0; i < 2005; i++ {
|
|
name := fmt.Sprintf("%04d.txt", i)
|
|
taskFileWrite(t, run, name, []byte("x"))
|
|
tm := stamp.Add(time.Duration(i) * time.Second)
|
|
if err := os.Chtimes(filepath.Join(run, name), tm, tm); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
all := taskFileListingResult(t, get("313", "all", ""))
|
|
if !all.Truncated || all.Total != 2005 || all.Size != 2005 || len(all.Entries) != 2000 {
|
|
t.Fatalf("counts: total=%d entries=%d truncated=%v", all.Total, len(all.Entries), all.Truncated)
|
|
}
|
|
if all.Entries[0].Name != "2004.txt" || all.Entries[1999].Name != "0005.txt" {
|
|
t.Fatal("truncated before sorting")
|
|
}
|
|
for i := 1; i < len(all.Entries); i++ {
|
|
if newerTaskFile(all.Entries[i], all.Entries[i-1]) {
|
|
t.Fatal("not sorted")
|
|
}
|
|
}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
root, err := os.OpenRoot(run)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer root.Close()
|
|
if _, err := scanTaskFiles(httptest.NewRequest("GET", "/", nil).WithContext(ctx), root); err == nil {
|
|
t.Fatal("ignored cancellation")
|
|
}
|
|
}
|
|
|
|
func TestTaskFilesAuthAndHead(t *testing.T) {
|
|
s, run, _ := taskFilesFixture(t)
|
|
s.jwtKey = []byte("task-files-test-signing-key")
|
|
taskFileWrite(t, run, "readme.txt", []byte("read only"))
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("GET /api/tasks/{id}/files", s.taskFilesList)
|
|
mux.HandleFunc("GET /api/tasks/{id}/files/all", s.taskFilesAll)
|
|
for _, action := range []string{"read", "download", "preview"} {
|
|
mux.HandleFunc("GET /api/tasks/{id}/files/"+action, s.taskFilesServe)
|
|
}
|
|
mux.HandleFunc("GET /api/tasks/{id}/files/archive", s.taskFilesArchive)
|
|
handler := s.requireAuth(mux)
|
|
for _, action := range []string{"", "/all", "/read", "/download", "/preview", "/archive"} {
|
|
req := httptest.NewRequest("GET", "/api/tasks/313/files"+action+"?path=readme.txt", nil)
|
|
rec := httptest.NewRecorder()
|
|
handler.ServeHTTP(rec, req)
|
|
if rec.Code != 401 {
|
|
t.Fatalf("unauthenticated %s: %d", action, rec.Code)
|
|
}
|
|
}
|
|
token, err := signJWT(s.jwtKey)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, action := range []string{"archive", "download"} {
|
|
path := "readme.txt"
|
|
if action == "archive" {
|
|
path = ""
|
|
}
|
|
req := httptest.NewRequest("HEAD", "/api/tasks/313/files/"+action+"?path="+path, nil)
|
|
req.AddCookie(&http.Cookie{Name: "artex_token", Value: token})
|
|
rec := httptest.NewRecorder()
|
|
handler.ServeHTTP(rec, req)
|
|
if rec.Code != 200 || rec.Body.Len() != 0 {
|
|
t.Fatalf("HEAD %s %d body=%d", action, rec.Code, rec.Body.Len())
|
|
}
|
|
}
|
|
}
|
|
|
|
// Opt-in, strictly read-only smoke check against an existing run directory.
|
|
func TestTaskFilesRealDirectory(t *testing.T) {
|
|
base := os.Getenv("ARTEX_TASK_FILES_TEST_DIR")
|
|
if base == "" {
|
|
t.Skip("set ARTEX_TASK_FILES_TEST_DIR to read an existing task 313 directory")
|
|
}
|
|
m := &Manager{dir: base, tasks: map[string]*Task{"313": {ID: "313"}}}
|
|
s := &Server{m: m}
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("GET /api/tasks/{id}/files", s.taskFilesList)
|
|
mux.HandleFunc("GET /api/tasks/{id}/files/all", s.taskFilesAll)
|
|
mux.HandleFunc("GET /api/tasks/{id}/files/read", s.taskFilesServe)
|
|
mux.HandleFunc("GET /api/tasks/{id}/files/download", s.taskFilesServe)
|
|
mux.HandleFunc("GET /api/tasks/{id}/files/preview", s.taskFilesServe)
|
|
mux.HandleFunc("GET /api/tasks/{id}/files/archive", s.taskFilesArchive)
|
|
get := func(action, path string) *httptest.ResponseRecorder {
|
|
target := "/api/tasks/313/files"
|
|
if action != "" {
|
|
target += "/" + action
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
mux.ServeHTTP(rec, httptest.NewRequest("GET", target+"?path="+url.QueryEscape(path), nil))
|
|
return rec
|
|
}
|
|
root := taskFileListingResult(t, get("", ""))
|
|
all := taskFileListingResult(t, get("all", ""))
|
|
if len(root.Entries) == 0 || all.Total == 0 {
|
|
t.Fatal("real directory is empty")
|
|
}
|
|
textChecked, imageChecked := false, false
|
|
for _, entry := range all.Entries {
|
|
if !textChecked && strings.HasSuffix(entry.Path, ".txt") {
|
|
if rec := get("read", entry.Path); rec.Code != 200 {
|
|
t.Fatal("real text preview", rec.Code)
|
|
}
|
|
rec := get("download", entry.Path)
|
|
data, err := os.ReadFile(filepath.Join(base, "tasks", "313", entry.Path))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if rec.Code != 200 || !bytes.Equal(rec.Body.Bytes(), data) {
|
|
t.Fatal("real download mismatch")
|
|
}
|
|
textChecked = true
|
|
}
|
|
if !imageChecked && strings.HasSuffix(entry.Path, ".jpg") {
|
|
if rec := get("preview", entry.Path); rec.Code == 200 {
|
|
imageChecked = true
|
|
}
|
|
}
|
|
}
|
|
rec := get("archive", "")
|
|
if rec.Code != 200 {
|
|
t.Fatal("real zip", rec.Code)
|
|
}
|
|
zr, err := zip.NewReader(bytes.NewReader(rec.Body.Bytes()), int64(rec.Body.Len()))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
count := 0
|
|
for _, file := range zr.File {
|
|
if file.FileInfo().IsDir() {
|
|
continue
|
|
}
|
|
f, err := file.Open()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, err = io.Copy(io.Discard, f)
|
|
f.Close()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
count++
|
|
}
|
|
if count != all.Total || !textChecked {
|
|
t.Fatalf("real smoke: zip=%d total=%d text=%v", count, all.Total, textChecked)
|
|
}
|
|
t.Logf("read-only task 313: root entries=%d files=%d bytes=%d zip files=%d text/download=%v valid JPEG=%v", len(root.Entries), all.Total, all.Size, count, textChecked, imageChecked)
|
|
}
|
|
|
|
func TestTaskFilesIntentMetadata(t *testing.T) {
|
|
m, err := NewManager(t.TempDir(), "")
|
|
if err != nil {
|
|
t.Skipf("isolated PostgreSQL required: %v", err)
|
|
}
|
|
defer m.Close()
|
|
task, err := m.CreateTask("task files metadata", "", nil, 0, 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
numericID, _ := strconv.ParseInt(task.ID, 10, 64)
|
|
defer m.pg.DeleteTask(numericID)
|
|
id, err := task.Store.AddIntent(map[string]any{"summary": "Readable intent title"}, 1, nil, "planner")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
run := filepath.Join(m.dir, "tasks", task.ID)
|
|
taskFileWrite(t, run, fmt.Sprintf("i%d/output.txt", id), []byte("result"))
|
|
req := httptest.NewRequest("GET", "/api/tasks/"+task.ID+"/files", nil)
|
|
req.SetPathValue("id", task.ID)
|
|
rec := httptest.NewRecorder()
|
|
(&Server{m: m}).taskFilesList(rec, req)
|
|
listing := taskFileListingResult(t, rec)
|
|
if len(listing.Entries) != 1 || listing.Entries[0].IntentID != id || listing.Entries[0].IntentTitle != "Readable intent title" || listing.Entries[0].IntentState != "open" {
|
|
t.Fatalf("intent metadata: %+v", listing.Entries)
|
|
}
|
|
}
|