ZDDC/zddc/internal/handler/zipwrite.go
ZDDC fcb8fc6cf1 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>
2026-06-05 14:28:06 -05:00

255 lines
6.9 KiB
Go

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)
}