feat(server): edit-in-place for the .zddc.zip config bundle, with in-zip history
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/<member>/<ts> + 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/<dir>/.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) <noreply@anthropic.com>
This commit is contained in:
parent
d878bc87e9
commit
fcb8fc6cf1
4 changed files with 371 additions and 2 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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": "<!doctype 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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
255
zddc/internal/handler/zipwrite.go
Normal file
255
zddc/internal/handler/zipwrite.go
Normal file
|
|
@ -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/<member>/<RFC3339-nano timestamp> the prior bytes
|
||||
// .history/<member>/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)
|
||||
}
|
||||
31
zddc/internal/handler/zipwrite_rt_test.go
Normal file
31
zddc/internal/handler/zipwrite_rt_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue