zddc-server can now hand back a whole directory subtree as a single
streamed application/zip download: GET /some/dir/?zip=1 (works on both
/dir and /dir/) → Content-Type: application/zip + Content-Disposition:
attachment; filename="<dir>.zip", containing every readable file under
/some/dir/, recursively.
handler.ServeSubtreeZip walks the tree with filepath.WalkDir, ACL-gates
each file by the .zddc chain of its containing directory (per-dir
decision cache, same shape as serveArchiveListing), skips hidden
entries ("." and "_" prefixes — .zddc, _template, _app), and adds a
.zip *file* it encounters as opaque bytes (it does not recurse into it
— that's the navigable-virtual-surface feature, a different thing).
The response is streamed (zip.NewWriter straight onto the
ResponseWriter, Store for already-compressed extensions, Deflate
otherwise), so a fully-ACL-denied or empty subtree just yields a valid
empty zip rather than a 403 (a stream can't change status after the
headers go out; empty leaks no more than 403). HEAD sends the headers
and no body. The dispatch's directory ACL gate still runs first, so a
viewer who can't read the directory gets 403 before the handler.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
210 lines
6.8 KiB
Go
210 lines
6.8 KiB
Go
package handler
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bytes"
|
|
"context"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"testing"
|
|
|
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
|
|
)
|
|
|
|
func mkfile(t *testing.T, path, body string) {
|
|
t.Helper()
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// readZipResponse decodes the zip body of a recorded response into
|
|
// name → bytes.
|
|
func readZipResponse(t *testing.T, rec *httptest.ResponseRecorder) map[string][]byte {
|
|
t.Helper()
|
|
body := rec.Body.Bytes()
|
|
if len(body) == 0 {
|
|
return map[string][]byte{}
|
|
}
|
|
zr, err := zip.NewReader(bytes.NewReader(body), int64(len(body)))
|
|
if err != nil {
|
|
t.Fatalf("response body is not a valid zip: %v (%d bytes)", err, len(body))
|
|
}
|
|
out := map[string][]byte{}
|
|
for _, f := range zr.File {
|
|
rc, err := f.Open()
|
|
if err != nil {
|
|
t.Fatalf("open zip entry %q: %v", f.Name, err)
|
|
}
|
|
b, _ := io.ReadAll(rc)
|
|
rc.Close()
|
|
out[f.Name] = b
|
|
}
|
|
return out
|
|
}
|
|
|
|
func subtreeReq(t *testing.T, method, urlPath, email string) *http.Request {
|
|
t.Helper()
|
|
req := httptest.NewRequest(method, urlPath, nil)
|
|
req = req.WithContext(context.WithValue(req.Context(), EmailKey, email))
|
|
return req
|
|
}
|
|
|
|
func TestServeSubtreeZip(t *testing.T) {
|
|
root := t.TempDir()
|
|
mkfile(t, filepath.Join(root, ".zddc"), "acl:\n permissions:\n \"*\": r\n")
|
|
sub := filepath.Join(root, "Proj", "sub")
|
|
mkfile(t, filepath.Join(sub, "a.txt"), "AAA")
|
|
mkfile(t, filepath.Join(sub, "nested", "b.txt"), "BBB")
|
|
mkfile(t, filepath.Join(sub, ".zddc"), "acl:\n permissions:\n \"*\": r\n") // hidden — must not appear
|
|
mkfile(t, filepath.Join(sub, "_template", "t.txt"), "tmpl") // hidden dir — must not appear
|
|
// A locked subdir only owner@x can read.
|
|
mkfile(t, filepath.Join(sub, "secret", ".zddc"), "acl:\n inherit: false\n permissions:\n \"owner@x\": rwcda\n")
|
|
mkfile(t, filepath.Join(sub, "secret", "s.txt"), "SECRET")
|
|
// A .zip file inside the subtree — must appear as opaque bytes, not extracted.
|
|
writeTestZip(t, filepath.Join(sub, "Pack.zip"), map[string]string{"inner.txt": "INNER"})
|
|
|
|
cfg := config.Config{Root: root}
|
|
|
|
t.Run("headers + ACL-filtered contents", func(t *testing.T) {
|
|
req := subtreeReq(t, http.MethodGet, "/Proj/sub/?zip=1", "bob@x")
|
|
rec := httptest.NewRecorder()
|
|
ServeSubtreeZip(cfg, rec, req, sub)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status=%d body=%d bytes", rec.Code, rec.Body.Len())
|
|
}
|
|
if ct := rec.Header().Get("Content-Type"); ct != "application/zip" {
|
|
t.Errorf("Content-Type=%q", ct)
|
|
}
|
|
if cd := rec.Header().Get("Content-Disposition"); cd != `attachment; filename="sub.zip"` {
|
|
t.Errorf("Content-Disposition=%q", cd)
|
|
}
|
|
if src := rec.Header().Get("X-ZDDC-Source"); src != "subtree-zip" {
|
|
t.Errorf("X-ZDDC-Source=%q", src)
|
|
}
|
|
if cc := rec.Header().Get("Cache-Control"); cc != "no-store" {
|
|
t.Errorf("Cache-Control=%q", cc)
|
|
}
|
|
|
|
got := readZipResponse(t, rec)
|
|
var names []string
|
|
for n := range got {
|
|
names = append(names, n)
|
|
}
|
|
sort.Strings(names)
|
|
want := []string{"sub/Pack.zip", "sub/a.txt", "sub/nested/b.txt"}
|
|
if len(names) != len(want) {
|
|
t.Fatalf("zip entries = %v, want %v", names, want)
|
|
}
|
|
for i := range want {
|
|
if names[i] != want[i] {
|
|
t.Fatalf("zip entries = %v, want %v", names, want)
|
|
}
|
|
}
|
|
if string(got["sub/a.txt"]) != "AAA" {
|
|
t.Errorf("sub/a.txt = %q", got["sub/a.txt"])
|
|
}
|
|
// Pack.zip is opaque: its bytes are themselves a valid zip with inner.txt.
|
|
inner, err := zip.NewReader(bytes.NewReader(got["sub/Pack.zip"]), int64(len(got["sub/Pack.zip"])))
|
|
if err != nil {
|
|
t.Fatalf("sub/Pack.zip is not a zip: %v", err)
|
|
}
|
|
if len(inner.File) != 1 || inner.File[0].Name != "inner.txt" {
|
|
t.Errorf("sub/Pack.zip should contain just inner.txt; got %d entries", len(inner.File))
|
|
}
|
|
})
|
|
|
|
t.Run("owner sees the locked subdir too", func(t *testing.T) {
|
|
req := subtreeReq(t, http.MethodGet, "/Proj/sub/?zip=1", "owner@x")
|
|
rec := httptest.NewRecorder()
|
|
ServeSubtreeZip(cfg, rec, req, sub)
|
|
got := readZipResponse(t, rec)
|
|
if _, ok := got["sub/secret/s.txt"]; !ok {
|
|
t.Errorf("owner@x should see sub/secret/s.txt; got %v", keysOf(got))
|
|
}
|
|
if string(got["sub/secret/s.txt"]) != "SECRET" {
|
|
t.Errorf("sub/secret/s.txt = %q", got["sub/secret/s.txt"])
|
|
}
|
|
})
|
|
|
|
t.Run("HEAD sets headers, no body", func(t *testing.T) {
|
|
req := subtreeReq(t, http.MethodHead, "/Proj/sub/?zip=1", "bob@x")
|
|
rec := httptest.NewRecorder()
|
|
ServeSubtreeZip(cfg, rec, req, sub)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status=%d", rec.Code)
|
|
}
|
|
if rec.Header().Get("Content-Type") != "application/zip" {
|
|
t.Errorf("missing Content-Type on HEAD")
|
|
}
|
|
if rec.Body.Len() != 0 {
|
|
t.Errorf("HEAD body should be empty; got %d bytes", rec.Body.Len())
|
|
}
|
|
})
|
|
|
|
t.Run("method not allowed", func(t *testing.T) {
|
|
req := subtreeReq(t, http.MethodPost, "/Proj/sub/?zip=1", "bob@x")
|
|
rec := httptest.NewRecorder()
|
|
ServeSubtreeZip(cfg, rec, req, sub)
|
|
if rec.Code != http.StatusMethodNotAllowed {
|
|
t.Errorf("POST status=%d, want 405", rec.Code)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestServeSubtreeZip_AllDenied(t *testing.T) {
|
|
root := t.TempDir()
|
|
mkfile(t, filepath.Join(root, ".zddc"), "acl:\n permissions:\n \"*\": r\n")
|
|
locked := filepath.Join(root, "Proj", "locked")
|
|
mkfile(t, filepath.Join(locked, ".zddc"), "acl:\n inherit: false\n permissions:\n \"owner@x\": rwcda\n")
|
|
mkfile(t, filepath.Join(locked, "x.txt"), "x")
|
|
mkfile(t, filepath.Join(locked, "deep", "y.txt"), "y")
|
|
|
|
cfg := config.Config{Root: root}
|
|
req := subtreeReq(t, http.MethodGet, "/Proj/locked/?zip=1", "bob@x")
|
|
rec := httptest.NewRecorder()
|
|
ServeSubtreeZip(cfg, rec, req, locked)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status=%d, want 200 (empty zip)", rec.Code)
|
|
}
|
|
got := readZipResponse(t, rec)
|
|
if len(got) != 0 {
|
|
t.Errorf("expected empty zip for a fully-denied subtree; got %v", keysOf(got))
|
|
}
|
|
}
|
|
|
|
func TestServeSubtreeZip_Nonexistent(t *testing.T) {
|
|
root := t.TempDir()
|
|
mkfile(t, filepath.Join(root, ".zddc"), "acl:\n permissions:\n \"*\": r\n")
|
|
cfg := config.Config{Root: root}
|
|
missing := filepath.Join(root, "Proj", "working") // declared by the cascade, not on disk
|
|
req := subtreeReq(t, http.MethodGet, "/Proj/working/?zip=1", "bob@x")
|
|
rec := httptest.NewRecorder()
|
|
ServeSubtreeZip(cfg, rec, req, missing)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status=%d, want 200 (empty zip)", rec.Code)
|
|
}
|
|
if got := readZipResponse(t, rec); len(got) != 0 {
|
|
t.Errorf("expected empty zip for a nonexistent dir; got %v", keysOf(got))
|
|
}
|
|
if cd := rec.Header().Get("Content-Disposition"); cd != `attachment; filename="working.zip"` {
|
|
t.Errorf("Content-Disposition=%q", cd)
|
|
}
|
|
}
|
|
|
|
func keysOf(m map[string][]byte) []string {
|
|
var ks []string
|
|
for k := range m {
|
|
ks = append(ks, k)
|
|
}
|
|
sort.Strings(ks)
|
|
return ks
|
|
}
|