ZDDC/zddc/internal/handler/fileapi.go
ZDDC 84c1b58b66 docs: fix stale "fenced/private home" claims — default homes are shared
The auto_own_fenced mechanism (private per-creator home via inherit:false) still
exists, but the current default tree sets it NOWHERE — the working/staging/
incoming/reviewing <party> homes are auto_own but UNFENCED, so ancestor grants
(project_team: cr at working/) cascade in and they are shared team folders. Code
comments (file.go AutoOwnFenced, special.go WriteAutoOwnZddcFenced, ensure.go,
fileapi.go) and AGENTS.md (role model + the auto_own_fenced key) still described
per-user homes as fenced/private-by-default — a pre-reshape artifact.

Correct them: fencing is an opt-in not used by the default tree; the party homes
are unfenced/shared. No behavior change (grep finds no auto_own_fenced in
internal/zddc/defaults). From the deferred-findings triage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 19:57:13 -05:00

971 lines
37 KiB
Go

package handler
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)
// File API — authenticated CRUD over the served tree.
//
// PUT /<path> write or overwrite. Body = file bytes (capped
// by cfg.MaxWriteBytes). Auto-creates parent
// directories. Optional If-Match for optimistic
// concurrency. Returns 201 Created (new) or 200
// OK (overwrite) with ETag.
// DELETE /<path> remove a file. Optional If-Match. Refuses to
// delete directories or hidden paths.
// POST /<path> control verb dispatched by X-ZDDC-Op header:
// move: X-ZDDC-Destination is the new path.
// Atomic os.Rename. Optional If-Match.
// mkdir: create directory at /<path>/. Idempotent.
//
// All operations route through the same ACL chain as GET — but with
// policy action="write" so external Rego can split read from write.
// The internal decider treats both identically.
//
// Path posture matches the rest of the dispatch:
// - hidden segments (./_-prefixed) are 404'd
// - the apps cache directory _app is 404'd
// - traversal that escapes Root is 404'd
//
// Audit: every successful write logs a structured `file_write` event
// (op, path, email, status, bytes) at INFO. Failed writes log at WARN.
const (
headerOp = "X-ZDDC-Op"
headerDestination = "X-ZDDC-Destination"
opMove = "move"
opMkdir = "mkdir"
// opSSRRename / opPlanReview / opAcceptTransmittal are declared
// alongside their handler files. Listed in the dispatch switch
// below so they're discoverable from a single place.
)
// IsWriteMethod reports whether this method is handled by the file API.
// Used by the dispatcher to gate writes through ServeFileAPI before the
// read-path tree of static / app / directory handling.
func IsWriteMethod(method string) bool {
switch method {
case http.MethodPut, http.MethodDelete, http.MethodPost:
return true
}
return false
}
// ServeFileAPI is the entry point for write methods. The dispatcher
// has already verified the path doesn't contain reserved segments.
// Caller must have already enforced the dot-prefix / _app guards
// (these match dispatch's existing ones, but we re-check defensively).
func ServeFileAPI(cfg config.Config, w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodPut:
serveFilePut(cfg, w, r)
case http.MethodDelete:
serveFileDelete(cfg, w, r)
case http.MethodPost:
serveFilePost(cfg, w, r)
default:
w.Header().Set("Allow", "GET, HEAD, PUT, DELETE, POST, OPTIONS")
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
}
}
// resolveTargetPath validates urlPath, joins it onto cfg.Root, and
// rejects traversal/hidden segments. Returns absolute path + the
// cleaned URL path (with one leading "/").
func resolveTargetPath(cfg config.Config, urlPath string) (absPath, cleanURL string, ok bool, status int, msg string) {
if urlPath == "" || urlPath == "/" {
return "", "", false, http.StatusBadRequest, "empty path"
}
cleanURL = "/" + strings.Trim(urlPath, "/")
if strings.HasSuffix(urlPath, "/") {
cleanURL += "/"
}
// Dot-/underscore-prefixed paths are ordinary ACL-governed content now;
// the one reserved namespace, .zddc.d/, is admin-gated in authorizeAction
// (which all write verbs funnel through) rather than blocked here, so an
// admin can read/write the sidecar like normal files. See sidecar.go.
rel := filepath.FromSlash(strings.TrimPrefix(strings.TrimSuffix(cleanURL, "/"), "/"))
abs := filepath.Join(cfg.Root, rel)
if !strings.HasPrefix(abs, cfg.Root+string(filepath.Separator)) && abs != cfg.Root {
return "", "", false, http.StatusNotFound, "path traversal"
}
return abs, cleanURL, true, 0, ""
}
// authorizeAction runs the ACL chain for a verb-tagged write to absPath.
// The chain is computed from the closest existing ancestor (so writes
// that create a brand-new file inherit the parent directory's chain).
// Returns allowed=false with the response status already written on deny.
//
// All admin / WORM / ACL logic lives downstream in the decider's single
// bypass site (policy.InternalDecider.Allow). AllowActionFromChainP
// computes IsActiveAdmin from the chain and Principal.Elevated, with
// the strict-ancestor rule applied when action == ActionAdmin (the
// caller tags .zddc writes that way). The handler does NOT make
// admin/elevation decisions of its own — one bypass site, one helper.
func authorizeAction(cfg config.Config, w http.ResponseWriter, r *http.Request, absPath, urlPath, action string) bool {
// Evaluate the cascade at the target's LOGICAL parent — NOT the nearest
// on-disk ancestor. EffectivePolicy is virtual-path-aware: the embedded
// paths: cascade resolves per-folder behaviour for directories that don't
// exist on disk yet. A create deep under a not-yet-materialised canonical
// path — e.g. mkdir working/<party>/<name> when working/<party>/ has never
// been created — must see the working/ grant (document_controller rwcda,
// project_team cr). Walking up to the nearest existing dir would instead
// land on the shallower project-level grant (document_controller rw, no c)
// and wrongly deny create.
dir := filepath.Dir(absPath)
if dir != cfg.Root && !strings.HasPrefix(dir, cfg.Root+string(filepath.Separator)) {
dir = cfg.Root
}
p := PrincipalFromContext(r)
chain, err := zddc.EffectivePolicy(cfg.Root, dir)
if err != nil {
slog.Warn("file API ACL chain error", "path", absPath, "err", err)
}
// Hard reserve: writes anywhere under a .zddc.d/ segment are admin-only,
// and this overrides operator ACLs — a broad grant (e.g. `*: rwcd`) must
// never let a non-admin write the token store. Denying here (before the
// decider) leaves the admin path to proceed normally below. See sidecar.go.
if HasReservedSidecar(urlPath) && !ActiveAdminForSidecar(cfg, r, urlPath) {
writeForbidden(w, action)
return false
}
decider := DeciderFromContext(r)
allowed, _ := policy.AllowActionFromChainP(r.Context(), decider, chain, p, urlPath, action)
if !allowed {
writeForbidden(w, action)
return false
}
return true
}
// readBodyCapped consumes r.Body up to cfg.MaxWriteBytes. Returns 413
// on overflow. The body is read fully into memory — small / medium files
// are the dominant traffic and atomic write needs the whole payload before
// the rename. Streaming PUTs (chunked uploads, multi-part resumable)
// are out of scope for this iteration.
func readBodyCapped(cfg config.Config, w http.ResponseWriter, r *http.Request) ([]byte, bool) {
limit := cfg.MaxWriteBytes
if limit <= 0 {
limit = 256 * 1024 * 1024
}
// http.MaxBytesReader writes a 413 itself when the limit is hit
// during read, but its error message is not always recognizable —
// we wrap it to surface a clean status code from the wrapped error.
r.Body = http.MaxBytesReader(w, r.Body, limit)
body, err := io.ReadAll(r.Body)
if err != nil {
var maxErr *http.MaxBytesError
if errors.As(err, &maxErr) {
http.Error(w, "Request Entity Too Large", http.StatusRequestEntityTooLarge)
return nil, false
}
http.Error(w, "Bad Request — could not read body: "+err.Error(), http.StatusBadRequest)
return nil, false
}
return body, true
}
// fileETag returns the SHA-256 first-32-hex of bytes — the same scheme
// the static file serve handler uses, so PUT response ETags match what
// a subsequent GET would compute.
func fileETag(body []byte) string {
sum := sha256.Sum256(body)
return hex.EncodeToString(sum[:])[:32]
}
// fileETagOnDisk returns the ETag of the file at absPath (or "" if it
// doesn't exist). Used to evaluate If-Match on PUT/DELETE/MOVE.
func fileETagOnDisk(absPath string) (string, error) {
body, err := os.ReadFile(absPath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return "", nil
}
return "", err
}
return fileETag(body), nil
}
// checkIfMatch returns true if the request's If-Match header (when
// present) matches the current ETag for absPath. Empty header always
// passes. Wildcard ("*") passes iff the file exists. On precondition
// failure the response is written as 412 and false is returned.
//
// Special case for PUT: when allowMissing is true and the file doesn't
// exist, the wildcard "*" form fails (per RFC) but a specific ETag is
// treated as a no-current-file hit (412). This distinguishes
// create-new from update-existing semantically.
//
// Also honors If-Unmodified-Since (RFC 7232 §3.4): the request fails
// with 412 if the current file's mtime is strictly later than the
// header value. Used by the cache layer's offline-write outbox to
// detect concurrent modifications without ETag round-trips — the
// cached file's mtime (set from upstream's Last-Modified) becomes the
// base for the precondition. Either header (or both) can be present;
// both must pass.
func checkIfMatch(w http.ResponseWriter, r *http.Request, absPath string) bool {
if !checkIfUnmodifiedSince(w, r, absPath) {
return false
}
header := strings.TrimSpace(r.Header.Get("If-Match"))
if header == "" {
return true
}
current, err := fileETagOnDisk(absPath)
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return false
}
if header == "*" {
if current == "" {
http.Error(w, "Precondition Failed — target does not exist", http.StatusPreconditionFailed)
return false
}
return true
}
want := strings.Trim(header, `"`)
if want != current {
http.Error(w, "Precondition Failed — ETag mismatch", http.StatusPreconditionFailed)
return false
}
return true
}
// checkIfUnmodifiedSince evaluates the RFC 7232 §3.4 precondition.
// Returns true (pass) when the header is absent or unparseable, when
// the target file does not exist, or when the file's current mtime
// is at or before the header value. Returns false (fail) and writes a
// 412 response when the file has been modified after the header time.
//
// mtime comparison uses the file's mod time truncated to whole
// seconds — HTTP-Date format has 1-second resolution, so a finer
// comparison would spuriously fail on filesystems that retain ns
// precision. "After" therefore means strictly greater than the
// header value at second resolution.
func checkIfUnmodifiedSince(w http.ResponseWriter, r *http.Request, absPath string) bool {
header := strings.TrimSpace(r.Header.Get("If-Unmodified-Since"))
if header == "" {
return true
}
since, err := http.ParseTime(header)
if err != nil {
// Per RFC 7232: if the header value is unparseable, ignore.
return true
}
info, err := os.Stat(absPath)
if err != nil {
// Missing file → no resource to compare against. Pass.
return true
}
current := info.ModTime().Truncate(time.Second)
if current.After(since.Truncate(time.Second)) {
http.Error(w, "Precondition Failed — If-Unmodified-Since: file modified at "+current.UTC().Format(http.TimeFormat), http.StatusPreconditionFailed)
return false
}
return true
}
func serveFilePut(cfg config.Config, w http.ResponseWriter, r *http.Request) {
abs, cleanURL, ok, status, msg := resolveTargetPath(cfg, r.URL.Path)
if !ok {
http.Error(w, msg, status)
return
}
if strings.HasSuffix(cleanURL, "/") {
http.Error(w, "PUT must target a file, not a directory", http.StatusBadRequest)
return
}
// A PUT that would introduce a new party folder under a party_source
// peer (e.g. working/<newparty>/file, or filing into
// archive/<newparty>/received/) requires the party to be registered.
if rejected, why, _ := partySourceGate(cfg.Root, abs); rejected {
http.Error(w, why, http.StatusConflict)
return
}
// Register rows (ssr/<party>.yaml, mdl|rsk/<party>/<file>.yaml) are
// real files in the flat-peer layout — a PUT targets them directly,
// no virtual→canonical rewrite. The path-derived $party/name column
// is injected only on read (ServeInjectedRow) and stripped by the
// client before submit.
// Virtual received/ rewrite. When the PUT targets a file under the
// synthetic <workflow>/received/<file> URL, the canonical record is
// WORM — we can't write there. Convention: treat the drop as a
// review comment, write it into the workflow folder as
// <base>+C<n><suffix> where n increments past any existing comments
// on the same target. The target filename comes from the URL's
// final segment.
if vr := zddc.ResolveVirtualReceived(cfg.Root, cleanURL); vr.Resolved && !vr.IsRoot {
targetName := filepath.Base(vr.SuffixURL)
commentName, cerr := zddc.CommentResolvedName(vr.WorkflowAbs, targetName)
if cerr != nil {
http.Error(w, "Bad Request — comment upload requires a ZDDC-parseable target filename: "+cerr.Error(), http.StatusBadRequest)
return
}
// Race-fix: if the computed filename already exists (concurrent
// upload), step the counter forward until we find a free slot.
abs = filepath.Join(vr.WorkflowAbs, commentName)
for i := 0; i < 32; i++ {
if _, err := os.Stat(abs); errors.Is(err, os.ErrNotExist) {
break
} else if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
// Bump: recompute with one more existing sibling.
commentName, cerr = zddc.CommentResolvedName(vr.WorkflowAbs, targetName)
if cerr != nil {
http.Error(w, "Internal Server Error — comment counter: "+cerr.Error(), http.StatusInternalServerError)
return
}
abs = filepath.Join(vr.WorkflowAbs, commentName)
}
// Rewrite cleanURL so audit logs + response headers reflect
// the actual destination, not the virtual one. Surface to the
// client via X-ZDDC-Resolved-Path so the status line can show
// "Saved as <resolved name>".
cleanURL = vr.WorkflowURL + commentName
w.Header().Set("X-ZDDC-Resolved-Path", cleanURL)
// Continue with normal write flow — ACL on the workflow folder
// gates the write, and existed=false (new file) selects
// ActionCreate.
}
// Resolve canonical-folder casing on the way in (no side effects): a
// request for /Project/working/foo.md when the on-disk folder is
// Working/ should land in Working/, not create a duplicate sibling.
// The actual MkdirAll for missing canonical ancestors and the
// auto-own .zddc seeding happen after authorisation, below.
if r2, err := zddc.ResolveCanonicalPath(cfg.Root, abs); err == nil {
abs = r2
}
// Stat first so we can choose action=create vs action=write before the
// ACL gate runs — this matters because role grants may include `c` but
// not `w` (or vice versa), and the gate must check the right verb.
existed := false
if info, err := os.Stat(abs); err == nil {
if info.IsDir() {
http.Error(w, "Conflict — a directory exists at this path", http.StatusConflict)
return
}
existed = true
}
action := policy.ActionCreate
if existed {
action = policy.ActionWrite
}
// Config files (.zddc / .zddc.zip) always require `a` (admin/config-edit)
// regardless of create/overwrite — see configWriteAction.
action = configWriteAction(abs, action)
if !authorizeAction(cfg, w, r, abs, cleanURL, action) {
return
}
if !checkIfMatch(w, r, abs) {
return
}
body, ok := readBodyCapped(cfg, w, r)
if !ok {
return
}
// Now that the write is authorized, materialise any missing canonical
// ancestors and seed auto-own .zddc files for them.
if email := EmailFromContext(r); email != "" {
if _, err := zddc.EnsureCanonicalAncestors(cfg.Root, abs, email, 0o755); err != nil {
slog.Warn("ensure canonical ancestors", "path", abs, "err", err)
}
}
// Record files (mdl rows, rsk rows, ssr.yaml) route through
// WriteWithHistory which strips client-supplied audit fields,
// stamps server-managed ones, archives the prior version to
// <dir>/.history/<base>/, validates body fields against
// cascade-resolved field_codes, and enforces filename_format
// composition. Non-record YAML files (table.yaml, form.yaml,
// .zddc) and binary files take the plain write path below.
finalBody := body
stamped := false
if isRecordPath(abs) {
res, verrs, herr := WriteWithHistory(cfg, abs, cleanURL, body, EmailFromContext(r))
if herr != nil {
auditFile(r, "put", cleanURL, http.StatusInternalServerError, len(body), herr)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
if len(verrs) > 0 {
writeValidationErrors(w, verrs)
auditFile(r, "put", cleanURL, http.StatusUnprocessableEntity, len(body), fmt.Errorf("validation: %d errors", len(verrs)))
return
}
finalBody = res.FinalBody
stamped = true
} else if IsTextHistoryCandidate(cfg.Root, abs) && zddc.HistoryAt(cfg.Root, filepath.Dir(abs)) {
// History-enabled text (markdown) files: snapshot every save
// into <dir>/.history/<stem>/ with a server-stamped audit line,
// then write the live file. The live file at its natural path
// remains the source of truth.
if err := WriteTextWithHistory(abs, body, EmailFromContext(r)); err != nil {
auditFile(r, "put", cleanURL, http.StatusInternalServerError, len(body), err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
} else {
if err := zddc.WriteAtomic(abs, body); err != nil {
auditFile(r, "put", cleanURL, http.StatusInternalServerError, len(body), err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
}
// Invalidate ETag cache (static.go memoizes by mtime; rename produces
// a fresh mtime so a stale entry is harmless, but clearing is cheap).
etagCacheM.Delete(abs)
// Invalidate any cached MD→{docx,html,pdf} conversions sitting in
// the sibling .converted/ dir for this source.
purgeConverted(abs)
etag := fileETag(finalBody)
w.Header().Set("ETag", `"`+etag+`"`)
w.Header().Set("X-ZDDC-Source", "fileapi:put")
respStatus := http.StatusCreated
if existed {
respStatus = http.StatusOK
}
// For record-stamped writes, echo the server-truth body so the
// tables save flow can update row.data without a re-GET. Other
// writes return no body (historical contract preserved).
if stamped {
w.Header().Set("Content-Type", "application/yaml")
w.WriteHeader(respStatus)
_, _ = w.Write(finalBody)
} else {
w.WriteHeader(respStatus)
}
auditFile(r, "put", cleanURL, respStatus, len(finalBody), nil)
}
// isRecordPath returns true if abs is a candidate for record-style
// handling (audit stamping + history). Excludes the well-known
// configuration filenames that share record directories: table.yaml
// (table spec), form.yaml (form schema), and .zddc (cascade
// configuration). Non-YAML extensions also fall through to the plain
// write path.
func isRecordPath(abs string) bool {
base := filepath.Base(abs)
switch base {
case "table.yaml", "form.yaml", ".zddc":
return false
}
ext := filepath.Ext(base)
if ext != ".yaml" && ext != ".yml" {
return false
}
// Exclude *.table.yaml and *.form.yaml (alternate spec naming).
if strings.HasSuffix(base, ".table.yaml") || strings.HasSuffix(base, ".form.yaml") {
return false
}
return true
}
func serveFileDelete(cfg config.Config, w http.ResponseWriter, r *http.Request) {
abs, cleanURL, ok, status, msg := resolveTargetPath(cfg, r.URL.Path)
if !ok {
http.Error(w, msg, status)
return
}
// (Directory vs file is decided by stat below; the client sends a folder
// DELETE with a trailing slash. A directory delete is admin-gated.)
// Register rows are real files — a DELETE targets them directly with
// the normal ACL gate. (Deleting an ssr/<party>.yaml de-registers the
// party; the ssr/ ACL grants delete only to admins by default so a
// party with archived records can't be orphaned.)
info, err := os.Stat(abs)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
http.Error(w, "Not Found", http.StatusNotFound)
return
}
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
if info.IsDir() {
// Directory delete is recursive (os.RemoveAll), which bypasses the
// per-file WORM/delete gates protecting the contents — so it's
// admin-only: an active admin over this subtree (a root admin, or a
// subtree admin within scope). This is the "admin mode exists for
// restructuring" capability.
p := PrincipalFromContext(r)
if !zddc.IsSubtreeAdmin(cfg.Root, abs, p) {
http.Error(w, "Forbidden — deleting a directory requires admin authority over it", http.StatusForbidden)
return
}
if err := os.RemoveAll(abs); err != nil {
auditFile(r, "delete", cleanURL+" (recursive)", http.StatusInternalServerError, 0, err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
etagCacheM.Delete(abs)
purgeConverted(abs)
w.Header().Set("X-ZDDC-Source", "fileapi:delete-dir")
w.WriteHeader(http.StatusNoContent)
auditFile(r, "delete", cleanURL+" (recursive)", http.StatusNoContent, 0, nil)
return
}
// File delete: a trailing slash is a directory URL — reject the mismatch.
if strings.HasSuffix(cleanURL, "/") {
http.Error(w, "DELETE must target a file, not a directory", http.StatusBadRequest)
return
}
// Config files (.zddc / .zddc.zip) require `a` (admin/config-edit) to
// delete — see configWriteAction.
action := configWriteAction(abs, policy.ActionDelete)
if !authorizeAction(cfg, w, r, abs, cleanURL, action) {
return
}
if !checkIfMatch(w, r, abs) {
return
}
if err := os.Remove(abs); err != nil {
auditFile(r, "delete", cleanURL, http.StatusInternalServerError, 0, err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
etagCacheM.Delete(abs)
purgeConverted(abs)
w.Header().Set("X-ZDDC-Source", "fileapi:delete")
w.WriteHeader(http.StatusNoContent)
auditFile(r, "delete", cleanURL, http.StatusNoContent, 0, nil)
}
func serveFilePost(cfg config.Config, w http.ResponseWriter, r *http.Request) {
op := strings.ToLower(strings.TrimSpace(r.Header.Get(headerOp)))
switch op {
case opMove:
serveFileMove(cfg, w, r)
case opMkdir:
serveFileMkdir(cfg, w, r)
case opPlanReview:
servePlanReview(cfg, w, r)
case opAcceptTransmittal:
serveAcceptTransmittal(cfg, w, r)
case opSSRRename:
serveSSRRename(cfg, w, r)
case "":
http.Error(w, "Bad Request — missing "+headerOp+" header", http.StatusBadRequest)
default:
http.Error(w, "Bad Request — unknown "+headerOp+" value: "+op, http.StatusBadRequest)
}
}
func serveFileMove(cfg config.Config, w http.ResponseWriter, r *http.Request) {
srcAbs, srcURL, ok, status, msg := resolveTargetPath(cfg, r.URL.Path)
if !ok {
http.Error(w, msg, status)
return
}
// (A trailing slash on src/dst signals a directory target; we no longer
// reject it here — file-vs-directory is decided by stat below, and a
// directory move is admin-gated.)
dstHeader := r.Header.Get(headerDestination)
if dstHeader == "" {
http.Error(w, "Bad Request — missing "+headerDestination+" header", http.StatusBadRequest)
return
}
// Destination is sent as a URL path; decode percent-encoding.
if dec, err := url.PathUnescape(dstHeader); err == nil {
dstHeader = dec
}
dstAbs, dstURL, ok, status, msg := resolveTargetPath(cfg, dstHeader)
if !ok {
http.Error(w, "destination: "+msg, status)
return
}
// A move whose destination introduces a new party folder under a
// party_source peer requires the party to be registered.
if rejected, why, _ := partySourceGate(cfg.Root, dstAbs); rejected {
http.Error(w, "destination: "+why, http.StatusConflict)
return
}
// Resolve canonical-folder casing on src + dst (no side effects).
if r2, err := zddc.ResolveCanonicalPath(cfg.Root, srcAbs); err == nil {
srcAbs = r2
}
if r2, err := zddc.ResolveCanonicalPath(cfg.Root, dstAbs); err == nil {
dstAbs = r2
}
// Source must exist as a regular file.
srcInfo, err := os.Stat(srcAbs)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
http.Error(w, "Not Found", http.StatusNotFound)
} else {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
return
}
isDir := srcInfo.IsDir()
if isDir {
// Directory moves relocate the whole subtree with one os.Rename,
// which sidesteps the per-file WORM/ACL gates protecting the
// descendants — so they're admin-only: an active admin over BOTH the
// source subtree and the destination's parent (a root admin covers
// all; a subtree admin within their own scope). This is the "admin
// mode exists for restructuring" capability.
p := PrincipalFromContext(r)
if !zddc.IsSubtreeAdmin(cfg.Root, srcAbs, p) ||
!zddc.IsSubtreeAdmin(cfg.Root, filepath.Dir(dstAbs), p) {
http.Error(w, "Forbidden — moving a directory requires admin authority over the source and destination", http.StatusForbidden)
return
}
// Refuse moving a directory into itself or one of its descendants.
if dstAbs == srcAbs || strings.HasPrefix(dstAbs, srcAbs+string(filepath.Separator)) {
http.Error(w, "Conflict — cannot move a directory into itself", http.StatusConflict)
return
}
} else if strings.HasSuffix(dstURL, "/") {
// A file move must target a file path, not a directory URL.
http.Error(w, "destination: MOVE of a file must target a file path", http.StatusBadRequest)
return
}
// Destination must not exist (no implicit overwrite). If-Match on the
// SOURCE is still respected for concurrency on the source bytes.
if _, err := os.Stat(dstAbs); err == nil {
http.Error(w, "Conflict — destination already exists", http.StatusConflict)
return
} else if !errors.Is(err, os.ErrNotExist) {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
// ACL: source side requires `w` (rename mutates the source); dest
// side requires `c` (creates a new path). Cross-folder moves run
// both gates against potentially different chains.
//
// Config files (.zddc / .zddc.zip) are policy, not content: relocating
// one mutates policy at BOTH ends (removing it from the source dir,
// installing it at the dest), so each side escalates to ActionAdmin —
// the same VerbA/config-edit bar PUT and DELETE enforce. Without this a
// caller holding only `w`/`c` could plant an attacker-controlled cascade
// (admins:/acl:) via the header-borne destination, which no dispatch
// gate inspects. See configWriteAction.
if !authorizeAction(cfg, w, r, srcAbs, srcURL, configWriteAction(srcAbs, policy.ActionWrite)) {
return
}
if !authorizeAction(cfg, w, r, dstAbs, dstURL, configWriteAction(dstAbs, policy.ActionCreate)) {
return
}
// If-Match concurrency applies to the source bytes — only meaningful for
// a file. A directory carries no ETag, so skip the precondition.
if !isDir {
if !checkIfMatch(w, r, srcAbs) {
return
}
}
// Ensure destination's canonical ancestors are created (with auto-own
// .zddc seeding) before the rename. This lets a MOVE from working/foo
// → archive/<party>/issued/foo materialise the per-party folders on
// the way in.
if email := EmailFromContext(r); email != "" {
if _, err := zddc.EnsureCanonicalAncestors(cfg.Root, dstAbs, email, 0o755); err != nil {
slog.Warn("ensure canonical ancestors (move dst)", "path", dstAbs, "err", err)
}
}
if err := os.MkdirAll(filepath.Dir(dstAbs), 0o755); err != nil {
auditFile(r, "move", srcURL, http.StatusInternalServerError, 0, err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
if err := os.Rename(srcAbs, dstAbs); err != nil {
// Cross-device or permission errors: report 500 — the client
// will retry or surface the failure to the user.
auditFile(r, "move", srcURL, http.StatusInternalServerError, 0, err)
http.Error(w, "Internal Server Error — rename failed: "+err.Error(), http.StatusInternalServerError)
return
}
etagCacheM.Delete(srcAbs)
etagCacheM.Delete(dstAbs)
purgeConverted(srcAbs)
purgeConverted(dstAbs)
// Carry edit-history across an in-place rename: if a markdown file was
// renamed within the same directory, move its .history/<stem>/ folder to
// match the new name. A cross-directory move deliberately leaves history
// behind (it lives forever in the dir where the edits happened).
if IsTextHistoryCandidate(cfg.Root, srcAbs) && filepath.Dir(srcAbs) == filepath.Dir(dstAbs) {
oldHist := mdHistoryDir(srcAbs)
newHist := mdHistoryDir(dstAbs)
if oldHist != newHist {
if _, err := os.Stat(oldHist); err == nil {
if _, derr := os.Stat(newHist); errors.Is(derr, os.ErrNotExist) {
if rerr := os.Rename(oldHist, newHist); rerr != nil {
slog.Warn("rename history dir", "from", oldHist, "to", newHist, "err", rerr)
}
}
}
}
}
// Compute new ETag from the moved bytes for the response — clients
// that want to keep tracking should pin to this ETag.
if etag, err := fileETagOnDisk(dstAbs); err == nil && etag != "" {
w.Header().Set("ETag", `"`+etag+`"`)
}
w.Header().Set("X-ZDDC-Source", "fileapi:move")
w.Header().Set("X-ZDDC-Destination", dstURL)
w.WriteHeader(http.StatusOK)
auditFile(r, "move", srcURL+" -> "+dstURL, http.StatusOK, 0, nil)
}
func serveFileMkdir(cfg config.Config, w http.ResponseWriter, r *http.Request) {
abs, cleanURL, ok, status, msg := resolveTargetPath(cfg, r.URL.Path)
if !ok {
http.Error(w, msg, status)
return
}
// Project-root mkdir policy: the only physical child allowed
// directly under <project>/ is `archive` (plus _/.-prefixed
// system names). Mkdir of any other name — including the six
// non-peer name — is rejected with 409.
if rejected, why := rejectProjectRootMkdir(cfg.Root, abs); rejected {
http.Error(w, why, http.StatusConflict)
return
}
// A new party folder under a party_source peer requires the party to
// be registered (ssr/<party>.yaml exists); else 409.
if rejected, why := rejectUnregisteredPartyMkdir(cfg.Root, abs); rejected {
http.Error(w, why, http.StatusConflict)
return
}
// Resolve canonical-folder casing on the way in (no side effects).
if r2, err := zddc.ResolveCanonicalPath(cfg.Root, abs); err == nil {
abs = r2
}
if !authorizeAction(cfg, w, r, abs, cleanURL, policy.ActionCreate) {
return
}
// Idempotent: if the dir already exists, treat it as success;
// if a file is at the path, conflict.
if info, err := os.Stat(abs); err == nil {
if info.IsDir() {
w.Header().Set("X-ZDDC-Source", "fileapi:mkdir")
w.WriteHeader(http.StatusOK)
auditFile(r, "mkdir", cleanURL, http.StatusOK, 0, nil)
return
}
http.Error(w, "Conflict — a file exists at this path", http.StatusConflict)
return
}
// Materialise any missing canonical ancestors (working/, staging/,
// archive/<party>/incoming/) before creating the target itself. This
// also seeds auto-own .zddc on each newly-created canonical ancestor.
email := EmailFromContext(r)
if email != "" {
if _, err := zddc.EnsureCanonicalAncestors(cfg.Root, abs, email, 0o755); err != nil {
slog.Warn("ensure canonical ancestors", "path", abs, "err", err)
}
}
if err := os.MkdirAll(abs, 0o755); err != nil {
auditFile(r, "mkdir", cleanURL, http.StatusInternalServerError, 0, err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
// Auto-ownership for the newly-created directory. The .zddc
// cascade's `auto_own:` flag (see internal/zddc/defaults/) drives this,
// same as EnsureCanonicalAncestors. A creator-owned .zddc lands
// inside abs when:
// - abs itself is declared auto_own (e.g. an explicit mkdir of
// /Project/working), or
// - abs's parent is declared auto_own — every child mkdir under
// an auto-own folder (working/, staging/, archive/<party>/,
// archive/<party>/incoming/, …) gets the creator's grant.
// The fence (inherit:false) follows abs's own cascade level via
// AutoOwnFencedAt. It is an opt-in the default tree does not set —
// the working/staging/incoming/reviewing party homes are auto-owned
// but UNFENCED, so ancestor grants (e.g. project_team cr) cascade
// through and they behave as shared team folders. An operator can
// set auto_own_fenced on a position to make it private.
if email != "" {
if zddc.AutoOwnAt(cfg.Root, abs) || zddc.AutoOwnAt(cfg.Root, filepath.Dir(abs)) {
roles := zddc.AutoOwnRolesAt(cfg.Root, abs)
var werr error
if zddc.AutoOwnFencedAt(cfg.Root, abs) {
werr = zddc.WriteAutoOwnZddcFenced(abs, email, roles)
} else {
werr = zddc.WriteAutoOwnZddc(abs, email, roles)
}
if werr != nil {
slog.Warn("auto-own .zddc write failed", "path", abs, "err", werr)
}
}
}
// (The pre-reshape staging↔working mirror was retired: with
// staging at archive/<party>/staging/<batch>/ and working at
// archive/<party>/working/<email>/, the project-level pairing
// no longer maps cleanly. Operators who want a per-batch drafting
// space create it inside their own working/<email>/ home.)
w.Header().Set("X-ZDDC-Source", "fileapi:mkdir")
w.WriteHeader(http.StatusCreated)
auditFile(r, "mkdir", cleanURL, http.StatusCreated, 0, nil)
}
// rejectProjectRootMkdir reports whether a mkdir at abs lands at
// <project>/<name>/ where <name> is forbidden as a direct project-
// root physical child. Under the canonical layout:
//
// - `archive` is the only physical project-root canonical folder
// - `_`-/`.`-prefixed names are system-reserved and allowed
// - the six virtual aggregator names (ssr/mdl/rsk/working/staging/
// reviewing) are explicitly rejected — the virtual resolver
// would shadow any physical folder created at those URLs
// - any other name is rejected: project-root mkdir of an ad-hoc
// name was an artefact of the pre-reshape layout where doc
// controllers could create freeform top-level folders, but the
// new model treats the project root as exclusively system + the
// archive/ party-holder.
//
// Returns (true, reason) when the request should be 409'd. Returns
// (false, "") when the target is at any other depth or carries an
// allowed name.
func rejectProjectRootMkdir(fsRoot, abs string) (bool, string) {
rel, err := filepath.Rel(fsRoot, abs)
if err != nil {
return false, ""
}
rel = filepath.ToSlash(rel)
if rel == "." || strings.HasPrefix(rel, "../") {
return false, ""
}
parts := strings.Split(rel, "/")
if len(parts) != 2 {
// Not a direct project-root child — depth-2 = <project>/<name>.
return false, ""
}
name := parts[1]
if strings.HasPrefix(name, "_") || strings.HasPrefix(name, ".") {
// System-reserved namespace; allowed.
return false, ""
}
if zddc.IsProjectPeer(name) {
return false, ""
}
return true, "Conflict — only the canonical peers (archive, incoming, working, staging, reviewing, mdl, rsk, ssr) and system-reserved (_/. prefix) folders may be created directly under a project."
}
// rejectUnregisteredPartyMkdir enforces the party_source cascade key: a
// new <party> folder under a peer that declares party_source (every peer
// except ssr/) may be created only if the party is registered — i.e. the
// registry entry exists (ssr/<party>.yaml). Applies at the party-segment
// depth and below (<project>/<peer>/<party>[/...]). Registration itself
// (creating ssr/<party>.yaml) is not gated — ssr/ sets no party_source.
func rejectUnregisteredPartyMkdir(fsRoot, abs string) (bool, string) {
reject, msg, _ := partySourceGate(fsRoot, abs)
return reject, msg
}
// partySourceGate is the shared party_source check used by mkdir, PUT
// (create), and move (dst). It returns reject=true (+ a 409 message)
// when abs would introduce a <party> segment under a party_source peer
// for a party that isn't registered. The third return is the resolved
// party name (for callers that want to log it).
func partySourceGate(fsRoot, abs string) (reject bool, msg, party string) {
rel, err := filepath.Rel(fsRoot, abs)
if err != nil {
return false, "", ""
}
rel = filepath.ToSlash(rel)
if rel == "." || strings.HasPrefix(rel, "../") {
return false, "", ""
}
parts := strings.Split(rel, "/")
if len(parts) < 3 {
return false, "", "" // <project>/<peer> — no party segment yet
}
project, peer, p := parts[0], parts[1], parts[2]
source := zddc.PartySourceAt(fsRoot, filepath.Join(fsRoot, project, peer))
if source == "" {
return false, "", "" // peer does no party gating (e.g. ssr/)
}
// The gate only guards INTRODUCING a new party. Once the party
// directory exists on disk the party is established, so a PUT/move
// into its existing subtree (e.g. editing a file already filed under
// working/<party>/…) must not be blocked — the registration check is
// an onboarding guard, not a write gate. Without this, editing any
// pre-existing file under a party folder whose registry row is
// missing or differently-cased 409s on save.
if fi, err := os.Stat(filepath.Join(fsRoot, project, peer, p)); err == nil && fi.IsDir() {
return false, "", p
}
if zddc.PartyRegistered(filepath.Join(fsRoot, project), source, p) {
return false, "", p
}
return true, "Conflict — unknown party \"" + p + "\". Register it first by creating " + source + "/" + p + ".yaml (the SSR form).", p
}
// auditFile emits a structured log line for each file API operation.
// AccessLogMiddleware already logs every request — this adds an
// op-tagged line so audit consumers can filter by operation without
// pattern-matching on method + path.
func auditFile(r *http.Request, op, path string, status any, bytes int, err error) {
email := EmailFromContext(r)
if email == "" {
email = "anonymous"
}
args := []any{
"op", op,
"path", path,
"email", email,
"status", fmt.Sprint(status),
"bytes", bytes,
}
if err != nil {
args = append(args, "err", err.Error())
slog.Warn("file_write", args...)
return
}
slog.Info("file_write", args...)
}