From fcb8fc6cf1e4d9f9fd2bb9fb864cc667b1662fdf Mon Sep 17 00:00:00 2001 From: ZDDC Date: Fri, 5 Jun 2026 14:28:06 -0500 Subject: [PATCH] feat(server): edit-in-place for the .zddc.zip config bundle, with in-zip history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A zip is random-access (unlike a streamed .tgz), so a member can be rewritten in place. ServeZipWrite (handler/zipwrite.go) handles PUT (write/create a member) and DELETE (remove) inside the .zddc.zip bundle: read the whole archive, snapshot the prior member into an in-zip .history// + append a log.jsonl audit line, mutate, then write a fresh zip and atomically rename over the original (serialized on one mutex). After a write the policy cache is invalidated so .zddc policy members take effect immediately, and the apps.Bundle mtime-reload picks up tool-HTML edits. Gated to active admins and to the .zddc.zip bundle only (dispatch's bundle gate already 404s everyone else; content zips — transmittal/WORM packages — stay read-only and 405). Writing into the in-zip .history/ is refused (append-only). Also fixes a read collision: a .zddc member INSIDE a zip (e.g. a policy member, URL ".../.zddc.zip//.zddc") was being grabbed by the raw-.zddc-view handler and 500ing; that handler now excludes ".zip/" paths so the zip intercept serves the member. Tests: writer round-trip (incl. wildcard member); dispatch create+overwrite, policy-takes-effect, in-zip history audit, read-back, non-admin 404, content-zip 405. Co-Authored-By: Claude Opus 4.8 (1M context) --- zddc/cmd/zddc-server/main.go | 16 +- zddc/cmd/zddc-server/main_test.go | 71 ++++++ zddc/internal/handler/zipwrite.go | 255 ++++++++++++++++++++++ zddc/internal/handler/zipwrite_rt_test.go | 31 +++ 4 files changed, 371 insertions(+), 2 deletions(-) create mode 100644 zddc/internal/handler/zipwrite.go create mode 100644 zddc/internal/handler/zipwrite_rt_test.go diff --git a/zddc/cmd/zddc-server/main.go b/zddc/cmd/zddc-server/main.go index 56f4b22..3828c55 100644 --- a/zddc/cmd/zddc-server/main.go +++ b/zddc/cmd/zddc-server/main.go @@ -872,8 +872,11 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps // cascade summary so the user can see what's effective here. The // reserved-sidecar gate above already filtered out .zddc.d/.zddc, so // GET/HEAD land here for ordinary paths and PUT/DELETE/POST fall - // through to ServeFileAPI. - if handler.IsZddcFileRequest(urlPath) && (r.Method == http.MethodGet || r.Method == http.MethodHead) { + // through to ServeFileAPI. A .zddc *inside* a zip (".zip/…/.zddc", e.g. + // a policy member of the .zddc.zip bundle) is NOT a real on-disk file — + // it's served by the zip intercept below, so exclude it here. + if handler.IsZddcFileRequest(urlPath) && !strings.Contains(strings.ToLower(urlPath), ".zip/") && + (r.Method == http.MethodGet || r.Method == http.MethodHead) { handler.ServeZddcFile(cfg, w, r) return } @@ -971,6 +974,15 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps if strings.Contains(strings.ToLower(urlPath), ".zip/") { if zipAbs, member, ok := splitZipPath(cfg.Root, urlPath); ok { if handler.IsWriteMethod(r.Method) { + // In-place editing is allowed ONLY inside the .zddc.zip config + // bundle and ONLY for an active admin (the bundle gate above + // already 404s the bundle to everyone else). Content zips — + // transmittal packages, WORM records — stay read-only. + if strings.EqualFold(filepath.Base(zipAbs), apps.BundleName) && + activeAdminForBundle(cfg, r, urlPath) { + handler.ServeZipWrite(cfg, w, r, zipAbs, member) + return + } w.Header().Set("Allow", "GET, HEAD") http.Error(w, "Zip archives are read-only", http.StatusMethodNotAllowed) return diff --git a/zddc/cmd/zddc-server/main_test.go b/zddc/cmd/zddc-server/main_test.go index 4a8db55..06a280c 100644 --- a/zddc/cmd/zddc-server/main_test.go +++ b/zddc/cmd/zddc-server/main_test.go @@ -1193,3 +1193,74 @@ func TestDispatchBundleAdminView(t *testing.T) { t.Errorf("non-admin GET member: status=%d, want 404 (no by-name leak)", rec.Code) } } + +// TestDispatchBundleAdminWrite locks in edit-in-place for the .zddc.zip config +// bundle: an active admin can PUT/DELETE members (changing live policy), each +// edit snapshots the prior version into an in-zip .history/, non-admins get 404 +// (the bundle gate), and content zips stay read-only. +func TestDispatchBundleAdminWrite(t *testing.T) { + root := t.TempDir() + mustWrite(t, filepath.Join(root, ".zddc"), + "acl:\n permissions:\n \"alice@x\": rwcda\nadmins:\n - alice@x\n") + mustMkdir(t, filepath.Join(root, "Proj")) + writeRootBundle(t, root, map[string]string{"browse.html": "BUNDLE"}) + mustWriteZip(t, filepath.Join(root, "Proj", "Foo.zip"), map[string]string{"m.txt": "x"}) + + idx, err := archive.BuildIndex(root) + if err != nil { + t.Fatalf("BuildIndex: %v", err) + } + cfg := config.Config{Root: root, IndexPath: ".archive", EmailHeader: "X-Auth-Request-Email", MaxWriteBytes: 64 * 1024} + ring := handler.NewLogRing(10) + appsSrv, err := setupApps(cfg) + if err != nil { + t.Fatalf("setupApps: %v", err) + } + do := func(method, path, email string, elevated bool, body []byte) *httptest.ResponseRecorder { + var req *http.Request + if body != nil { + req = httptest.NewRequest(method, path, bytes.NewReader(body)) + } else { + req = httptest.NewRequest(method, path, nil) + } + ctx := context.WithValue(req.Context(), handler.EmailKey, email) + ctx = context.WithValue(ctx, handler.ElevatedKey, elevated) + req = req.WithContext(ctx) + rec := httptest.NewRecorder() + dispatch(cfg, idx, ring, appsSrv, nil, rec, req) + return rec + } + + // 1. Admin creates a policy member (governs the project level via "*"). + if rec := do(http.MethodPut, "/.zddc.zip/Proj/.zddc", "alice@x", true, + []byte("acl:\n permissions:\n \"team@x\": rwc\n")); rec.Code != http.StatusCreated { + t.Fatalf("PUT new member: status=%d body=%s, want 201", rec.Code, rec.Body.String()) + } + // 2. The edit took effect on the live cascade (write invalidated the cache). + zddc.InvalidateCache(root) + chain, _ := zddc.EffectivePolicy(root, filepath.Join(root, "Proj")) + if !zddc.EffectiveVerbs(chain, "team@x").Has(zddc.VerbC) { + t.Errorf("bundle policy edit didn't reach the cascade: team@x lacks create at /Proj") + } + // 3. Edit again (existing member → snapshots to .history/). + if rec := do(http.MethodPut, "/.zddc.zip/Proj/.zddc", "alice@x", true, + []byte("acl:\n permissions:\n \"team@x\": r\n")); rec.Code != http.StatusOK { + t.Fatalf("PUT overwrite: status=%d, want 200", rec.Code) + } + // 4. Read back the current member. + if rec := do(http.MethodGet, "/.zddc.zip/Proj/.zddc", "alice@x", true, nil); !strings.Contains(rec.Body.String(), "\"team@x\": r") { + t.Errorf("read-back body=%q, want the latest edit", rec.Body.String()) + } + // 5. The in-zip history log records the edit (audited with the editor email). + if rec := do(http.MethodGet, "/.zddc.zip/.history/Proj/.zddc/log.jsonl", "alice@x", true, nil); !strings.Contains(rec.Body.String(), "alice@x") { + t.Errorf("history log=%q, want an alice@x entry", rec.Body.String()) + } + // 6. Non-admin write → 404 (bundle existence-hidden to non-admins). + if rec := do(http.MethodPut, "/.zddc.zip/Proj/.zddc", "bob@x", true, []byte("x")); rec.Code != http.StatusNotFound { + t.Errorf("non-admin PUT: status=%d, want 404", rec.Code) + } + // 7. Content zips stay read-only — even for an admin. + if rec := do(http.MethodPut, "/Proj/Foo.zip/m.txt", "alice@x", true, []byte("y")); rec.Code != http.StatusMethodNotAllowed { + t.Errorf("content-zip PUT: status=%d, want 405", rec.Code) + } +} diff --git a/zddc/internal/handler/zipwrite.go b/zddc/internal/handler/zipwrite.go new file mode 100644 index 0000000..7e7ef87 --- /dev/null +++ b/zddc/internal/handler/zipwrite.go @@ -0,0 +1,255 @@ +package handler + +import ( + "archive/zip" + "encoding/json" + "io" + "net/http" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" + + "codeberg.org/VARASYS/ZDDC/zddc/internal/config" + "codeberg.org/VARASYS/ZDDC/zddc/internal/zddc" +) + +// Edit-in-place for the .zddc.zip config bundle. A zip is a random-access +// container (unlike a streamed .tgz), so a member can be rewritten without +// re-encoding the operator's intent — we read the whole archive, mutate one +// member, snapshot the old version into an in-zip .history/, and atomically +// replace the file. Gated to active admins (dispatch already 404s the bundle +// to everyone else) and to the .zddc.zip bundle specifically; content zips stay +// read-only. +// +// History layout INSIDE the bundle (so edits travel with it): +// +// .history// the prior bytes +// .history//log.jsonl append-only audit (ts, email, op) +// +// Writes serialize on one mutex — admin bundle edits are infrequent, and a +// whole-archive rewrite must not interleave. +var zipWriteMu sync.Mutex + +const zipHistoryDir = ".history" + +// ServeZipWrite handles PUT (write/create a member) and DELETE (remove a +// member) inside a .zddc.zip bundle. member is the path within the zip. +func ServeZipWrite(cfg config.Config, w http.ResponseWriter, r *http.Request, zipAbs, member string) { + member = strings.TrimLeft(member, "/") + if member == "" || strings.HasSuffix(member, "/") { + http.Error(w, "Bad Request — a zip member path is required", http.StatusBadRequest) + return + } + if member == zipHistoryDir || strings.HasPrefix(member, zipHistoryDir+"/") { + http.Error(w, "Forbidden — the in-zip .history/ store is append-only", http.StatusForbidden) + return + } + + switch r.Method { + case http.MethodPut: + body, ok := readBodyCapped(cfg, w, r) + if !ok { + return + } + zipMutate(cfg, w, r, zipAbs, member, body, false) + case http.MethodDelete: + zipMutate(cfg, w, r, zipAbs, member, nil, true) + default: + w.Header().Set("Allow", "PUT, DELETE") + http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) + } +} + +// zipLogLine is one append-only audit record in an in-zip log.jsonl. +type zipLogLine struct { + TS string `json:"ts"` + Email string `json:"email"` + Op string `json:"op"` + Bytes int `json:"bytes"` +} + +func zipMutate(cfg config.Config, w http.ResponseWriter, r *http.Request, zipAbs, member string, body []byte, del bool) { + zipWriteMu.Lock() + defer zipWriteMu.Unlock() + + members, order, err := readZipMembers(zipAbs) + if err != nil { + http.Error(w, "Internal Server Error — read bundle: "+err.Error(), http.StatusInternalServerError) + return + } + + old, existed := members[member] + if del && !existed { + http.Error(w, "Not Found", http.StatusNotFound) + return + } + + // Snapshot the prior bytes + append an audit line BEFORE mutating. + if existed { + ts := time.Now().UTC().Format("2006-01-02T15:04:05.000000000Z") + histPrefix := zipHistoryDir + "/" + member + "/" + addMember(members, &order, histPrefix+ts, old) + op := "put" + if del { + op = "delete" + } + line, _ := json.Marshal(zipLogLine{TS: ts, Email: EmailFromContext(r), Op: op, Bytes: len(body)}) + logKey := histPrefix + "log.jsonl" + members[logKey] = append(append(append([]byte{}, members[logKey]...), line...), '\n') + // log.jsonl may be newly created here. + ensureInOrder(&order, logKey) + } + + if del { + delete(members, member) + order = removeFromOrder(order, member) + } else { + addMember(members, &order, member, body) + } + + if err := writeZipAtomic(zipAbs, members, order); err != nil { + auditFile(r, zipOp(del), r.URL.Path, http.StatusInternalServerError, 0, err) + http.Error(w, "Internal Server Error — write bundle: "+err.Error(), http.StatusInternalServerError) + return + } + + // A .zddc.zip change can alter both policy (its .zddc members feed the + // cascade) and tool HTML (apps.Bundle, which hot-reloads on mtime). Clear + // the policy cache so the next decision re-reads the bundle. + zddc.InvalidateCache(cfg.Root) + + if del { + w.Header().Set("X-ZDDC-Source", "zip:delete") + w.WriteHeader(http.StatusNoContent) + auditFile(r, "zip-delete", r.URL.Path, http.StatusNoContent, 0, nil) + return + } + status := http.StatusOK + if !existed { + status = http.StatusCreated + } + w.Header().Set("ETag", `"`+fileETag(body)+`"`) + w.Header().Set("X-ZDDC-Source", "zip:put") + w.WriteHeader(status) + auditFile(r, "zip-put", r.URL.Path, status, len(body), nil) +} + +func zipOp(del bool) string { + if del { + return "zip-delete" + } + return "zip-put" +} + +// readZipMembers loads every member of the zip at zipAbs into a name→bytes map +// plus an order slice (insertion order, for stable rewrites). +func readZipMembers(zipAbs string) (map[string][]byte, []string, error) { + zr, err := zip.OpenReader(zipAbs) + if err != nil { + return nil, nil, err + } + defer zr.Close() + members := make(map[string][]byte, len(zr.File)) + order := make([]string, 0, len(zr.File)) + for _, f := range zr.File { + if f.FileInfo().IsDir() { + continue + } + rc, err := f.Open() + if err != nil { + return nil, nil, err + } + data, err := io.ReadAll(rc) + rc.Close() + if err != nil { + return nil, nil, err + } + if _, dup := members[f.Name]; !dup { + order = append(order, f.Name) + } + members[f.Name] = data + } + return members, order, nil +} + +func addMember(members map[string][]byte, order *[]string, name string, data []byte) { + if _, ok := members[name]; !ok { + *order = append(*order, name) + } + members[name] = data +} + +func ensureInOrder(order *[]string, name string) { + for _, n := range *order { + if n == name { + return + } + } + *order = append(*order, name) +} + +func removeFromOrder(order []string, name string) []string { + out := order[:0] + for _, n := range order { + if n != name { + out = append(out, n) + } + } + return out +} + +// writeZipAtomic writes members to a fresh zip in the same directory and renames +// it over zipAbs. Members are emitted in `order` (sorted as a tiebreak for any +// not in order) so rewrites are deterministic. +func writeZipAtomic(zipAbs string, members map[string][]byte, order []string) error { + // Any member not captured in order (defensive) goes last, sorted. + seen := make(map[string]bool, len(order)) + for _, n := range order { + seen[n] = true + } + var extra []string + for n := range members { + if !seen[n] { + extra = append(extra, n) + } + } + sort.Strings(extra) + names := append(append([]string{}, order...), extra...) + + tmp, err := os.CreateTemp(filepath.Dir(zipAbs), ".zddc.zip.tmp-*") + if err != nil { + return err + } + tmpName := tmp.Name() + defer os.Remove(tmpName) // no-op after a successful rename + + zw := zip.NewWriter(tmp) + for _, name := range names { + data, ok := members[name] + if !ok { + continue + } + fw, err := zw.Create(name) + if err != nil { + zw.Close() + tmp.Close() + return err + } + if _, err := fw.Write(data); err != nil { + zw.Close() + tmp.Close() + return err + } + } + if err := zw.Close(); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmpName, zipAbs) +} diff --git a/zddc/internal/handler/zipwrite_rt_test.go b/zddc/internal/handler/zipwrite_rt_test.go new file mode 100644 index 0000000..1d84718 --- /dev/null +++ b/zddc/internal/handler/zipwrite_rt_test.go @@ -0,0 +1,31 @@ +package handler + +import ( + "path/filepath" + "testing" +) + +func TestZipWriteRoundTrip(t *testing.T) { + zp := filepath.Join(t.TempDir(), ".zddc.zip") + if err := writeZipAtomic(zp, map[string][]byte{"a.txt": []byte("v1")}, []string{"a.txt"}); err != nil { + t.Fatalf("seed: %v", err) + } + m, ord, err := readZipMembers(zp) + if err != nil { + t.Fatalf("read1: %v", err) + } + addMember(m, &ord, "*/.zddc", []byte("hello-wildcard")) + if err := writeZipAtomic(zp, m, ord); err != nil { + t.Fatalf("write2: %v", err) + } + m2, _, err := readZipMembers(zp) + if err != nil { + t.Fatalf("read2: %v", err) + } + if got := string(m2["*/.zddc"]); got != "hello-wildcard" { + t.Errorf("wildcard member = %q, want hello-wildcard", got) + } + if got := string(m2["a.txt"]); got != "v1" { + t.Errorf("a.txt = %q, want v1", got) + } +}