226 lines
7.3 KiB
Go
226 lines
7.3 KiB
Go
package handler
|
|
|
|
import (
|
|
"archive/zip"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
|
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/listing"
|
|
)
|
|
|
|
func writeTestZip(t *testing.T, path string, entries map[string]string) {
|
|
t.Helper()
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
f, err := os.Create(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer f.Close()
|
|
zw := zip.NewWriter(f)
|
|
for name, body := range entries {
|
|
w, err := zw.Create(name)
|
|
if err != nil {
|
|
t.Fatalf("zip.Create(%q): %v", name, err)
|
|
}
|
|
if body != "<dir>" {
|
|
if _, err := w.Write([]byte(body)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
}
|
|
if err := zw.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestServeZip(t *testing.T) {
|
|
root := t.TempDir()
|
|
zipPath := filepath.Join(root, "P", "staging", "T.zip")
|
|
writeTestZip(t, zipPath, map[string]string{
|
|
"DOC-001 (IFI) - Spec.pdf": "PDF-CONTENT",
|
|
"sub/note.txt": "a note",
|
|
"sub/deep/x.bin": "\x00\x01\x02",
|
|
})
|
|
cfg := config.Config{Root: root}
|
|
|
|
t.Run("root listing JSON", func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "/P/staging/T.zip/", nil)
|
|
req.Header.Set("Accept", "application/json")
|
|
rec := httptest.NewRecorder()
|
|
ServeZip(cfg, rec, req, zipPath, "")
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
var fis []listing.FileInfo
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &fis); err != nil {
|
|
t.Fatalf("decode: %v; body=%s", err, rec.Body.String())
|
|
}
|
|
byName := map[string]listing.FileInfo{}
|
|
for _, fi := range fis {
|
|
byName[fi.Name] = fi
|
|
}
|
|
if fi, ok := byName["DOC-001 (IFI) - Spec.pdf"]; !ok || fi.IsDir {
|
|
t.Errorf("expected file entry; got %v", byName)
|
|
}
|
|
if fi, ok := byName["sub/"]; !ok || !fi.IsDir {
|
|
t.Errorf("expected sub/ dir entry; got %v", byName)
|
|
}
|
|
// URL is relative to the request path and percent-escaped.
|
|
if got := byName["DOC-001 (IFI) - Spec.pdf"].URL; got != "/P/staging/T.zip/DOC-001%20%28IFI%29%20-%20Spec.pdf" {
|
|
t.Errorf("file URL=%q want escaped form", got)
|
|
}
|
|
if got := byName["sub/"].URL; got != "/P/staging/T.zip/sub/" {
|
|
t.Errorf("dir URL=%q", got)
|
|
}
|
|
if rec.Header().Get("Vary") != "Accept" {
|
|
t.Errorf("missing Vary: Accept")
|
|
}
|
|
if rec.Header().Get("ETag") == "" {
|
|
t.Errorf("missing ETag")
|
|
}
|
|
})
|
|
|
|
t.Run("nested listing JSON", func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "/P/staging/T.zip/sub/", nil)
|
|
req.Header.Set("Accept", "application/json")
|
|
rec := httptest.NewRecorder()
|
|
ServeZip(cfg, rec, req, zipPath, "sub")
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
var fis []listing.FileInfo
|
|
json.Unmarshal(rec.Body.Bytes(), &fis)
|
|
byName := map[string]bool{}
|
|
for _, fi := range fis {
|
|
byName[fi.Name] = fi.IsDir
|
|
}
|
|
if d, ok := byName["note.txt"]; !ok || d {
|
|
t.Errorf("sub/ should contain file note.txt; got %v", byName)
|
|
}
|
|
if d, ok := byName["deep/"]; !ok || !d {
|
|
t.Errorf("sub/ should contain dir deep/; got %v", byName)
|
|
}
|
|
})
|
|
|
|
t.Run("file member extracted", func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "/P/staging/T.zip/sub/note.txt", nil)
|
|
rec := httptest.NewRecorder()
|
|
ServeZip(cfg, rec, req, zipPath, "sub/note.txt")
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
if rec.Body.String() != "a note" {
|
|
t.Errorf("body=%q", rec.Body.String())
|
|
}
|
|
if rec.Header().Get("X-ZDDC-Source") != "zip:T.zip" {
|
|
t.Errorf("X-ZDDC-Source=%q", rec.Header().Get("X-ZDDC-Source"))
|
|
}
|
|
// http.ServeContent sets Content-Type from the name (.txt).
|
|
if ct := rec.Header().Get("Content-Type"); ct == "" {
|
|
t.Errorf("missing Content-Type")
|
|
}
|
|
})
|
|
|
|
t.Run("file member case-insensitive", func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "/P/staging/T.zip/SUB/NOTE.TXT", nil)
|
|
rec := httptest.NewRecorder()
|
|
ServeZip(cfg, rec, req, zipPath, "SUB/NOTE.TXT")
|
|
if rec.Code != http.StatusOK || rec.Body.String() != "a note" {
|
|
t.Errorf("status=%d body=%q", rec.Code, rec.Body.String())
|
|
}
|
|
})
|
|
|
|
t.Run("range request on a member", func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "/P/staging/T.zip/DOC-001%20%28IFI%29%20-%20Spec.pdf", nil)
|
|
req.Header.Set("Range", "bytes=0-2")
|
|
rec := httptest.NewRecorder()
|
|
ServeZip(cfg, rec, req, zipPath, "DOC-001 (IFI) - Spec.pdf")
|
|
if rec.Code != http.StatusPartialContent {
|
|
t.Fatalf("status=%d, want 206; body=%q", rec.Code, rec.Body.String())
|
|
}
|
|
if rec.Body.String() != "PDF" {
|
|
t.Errorf("partial body=%q, want PDF", rec.Body.String())
|
|
}
|
|
})
|
|
|
|
t.Run("missing member 404", func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "/P/staging/T.zip/no/such.txt", nil)
|
|
rec := httptest.NewRecorder()
|
|
ServeZip(cfg, rec, req, zipPath, "no/such.txt")
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Errorf("status=%d, want 404", rec.Code)
|
|
}
|
|
})
|
|
|
|
t.Run("directory member without trailing slash 302s", func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "/P/staging/T.zip/sub", nil)
|
|
rec := httptest.NewRecorder()
|
|
ServeZip(cfg, rec, req, zipPath, "sub")
|
|
if rec.Code != http.StatusFound {
|
|
t.Fatalf("status=%d, want 302", rec.Code)
|
|
}
|
|
if loc := rec.Header().Get("Location"); loc != "/P/staging/T.zip/sub/" {
|
|
t.Errorf("Location=%q", loc)
|
|
}
|
|
})
|
|
|
|
t.Run("bad zip path", func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "/P/staging/Nope.zip/", nil)
|
|
rec := httptest.NewRecorder()
|
|
ServeZip(cfg, rec, req, filepath.Join(root, "P", "staging", "Nope.zip"), "")
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Errorf("status=%d, want 404", rec.Code)
|
|
}
|
|
})
|
|
|
|
t.Run("zip-slip member is unreachable", func(t *testing.T) {
|
|
// Build a zip with a malicious entry; the handler must not surface it.
|
|
evilZip := filepath.Join(root, "P", "staging", "Evil.zip")
|
|
writeTestZip(t, evilZip, map[string]string{"ok.txt": "fine"})
|
|
// Manually append nothing nasty via the safe writer (zip.Writer
|
|
// rejects "../" names? no — it allows them). Re-create with one.
|
|
f, _ := os.Create(evilZip)
|
|
zw := zip.NewWriter(f)
|
|
w1, _ := zw.Create("ok.txt")
|
|
w1.Write([]byte("fine"))
|
|
w2, _ := zw.Create("../escape.txt")
|
|
w2.Write([]byte("pwned"))
|
|
zw.Close()
|
|
f.Close()
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/P/staging/Evil.zip/x", nil)
|
|
rec := httptest.NewRecorder()
|
|
ServeZip(cfg, rec, req, evilZip, "../escape.txt")
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Errorf("zip-slip member status=%d, want 404", rec.Code)
|
|
}
|
|
// ...but the safe entry is fine.
|
|
req2 := httptest.NewRequest(http.MethodGet, "/P/staging/Evil.zip/ok.txt", nil)
|
|
rec2 := httptest.NewRecorder()
|
|
ServeZip(cfg, rec2, req2, evilZip, "ok.txt")
|
|
if rec2.Code != http.StatusOK || rec2.Body.String() != "fine" {
|
|
t.Errorf("safe member status=%d body=%q", rec2.Code, rec2.Body.String())
|
|
}
|
|
})
|
|
|
|
t.Run("HTML request serves something usable", func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "/P/staging/T.zip/", nil)
|
|
req.Header.Set("Accept", "text/html")
|
|
rec := httptest.NewRecorder()
|
|
ServeZip(cfg, rec, req, zipPath, "")
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status=%d", rec.Code)
|
|
}
|
|
ct := rec.Header().Get("Content-Type")
|
|
if ct != "text/html; charset=utf-8" && ct != "application/json" {
|
|
t.Errorf("Content-Type=%q, want html or json fallback", ct)
|
|
}
|
|
})
|
|
}
|