// Package handler — history.go: orchestrates writes of "record" YAML
// files (mdl rows, rsk rows, ssr.yaml) with three guarantees the
// generic file API cannot make on its own:
//
// 1. Audit fields are server-managed. created_at / created_by /
// updated_at / updated_by / revision / previous_sha are stripped
// from incoming bodies and stamped from the request context,
// making client-side forgery impossible.
//
// 2. Prior bytes are preserved. Before the live file is overwritten
// the previous content is copied (byte-for-byte) into
//
/.history//-.. The
// filename embeds the timestamp + the SHA-256 prefix of the prior
// bytes — the same value that's stamped into the new record's
// previous_sha field — so the chain is auditable.
//
// 3. Filename composition is enforced. When the matched RecordRule
// declares a filename_format, the server composes the expected
// basename from body fields and rejects writes whose URL doesn't
// agree. This binds the on-disk identity to the body's
// tracking-number components, eliminating drift.
//
// Records are identified by the cascade: a .zddc records: entry
// matched against the basename selects the rule. Files that don't
// match any rule fall through to a plain write — non-record YAML
// (table.yaml, form.yaml, plain documents) is unaffected.
package handler
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/jsonschema"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
"gopkg.in/yaml.v3"
)
// Audit-field key names. Snake-case to match the existing .zddc
// `created_by:` precedent rather than the camelCase used in form
// schemas (those describe domain data; these describe provenance).
const (
auditFieldCreatedAt = "created_at"
auditFieldCreatedBy = "created_by"
auditFieldUpdatedAt = "updated_at"
auditFieldUpdatedBy = "updated_by"
auditFieldRevision = "revision"
auditFieldPreviousSha = "previous_sha"
)
// HistoryDirName is the dot-prefixed history folder under each
// history-tracked directory. WRITES are server-only — the file API's
// segment check rejects client PUT/DELETE/POST into it. READS (GET/HEAD)
// are carved out of the dispatcher's dot-prefix guard so snapshots are
// fetchable as ordinary ACL-gated content (the .history subtree inherits
// the same .zddc chain as the files it shadows); the listing dot-filter
// still keeps it out of default views unless ?hidden is set.
const HistoryDirName = ".history"
// WriteRecordResult carries what serveFilePut needs to surface a
// response after a successful record write.
type WriteRecordResult struct {
FinalBody []byte // bytes actually written to disk (after stamping)
Created bool // true if no prior file existed (response 201 vs 200)
}
// WriteWithHistory orchestrates a record write at abs (which must be
// the canonical on-disk path — virtual-view rewriting already
// applied). cleanURL is the URL the caller surfaces (for audit
// logging). body is the raw request bytes.
//
// Returns:
// - res, nil, nil: success; caller writes 200/201 + ETag.
// - _, errs, nil: 422 with the validation errors (locked
// mismatch, field_code violation, filename
// composition mismatch).
// - _, _, err: internal error; caller writes 500.
//
// The function does NOT do ACL, ETag-precondition, or canonical-
// ancestor seeding — those are still serveFilePut's job and run
// before this call. The function DOES handle prior-bytes capture,
// audit stamping, history write, and live write.
func WriteWithHistory(cfg config.Config, abs, cleanURL string, body []byte, principalEmail string) (WriteRecordResult, []jsonschema.Error, error) {
dir := filepath.Dir(abs)
base := filepath.Base(abs)
// Resolve cascade at the record's parent dir.
chain, err := zddc.EffectivePolicy(cfg.Root, dir)
if err != nil {
return WriteRecordResult{}, nil, fmt.Errorf("effective policy: %w", err)
}
_, rule, hasRule := chain.EffectiveRecordRule(base)
// Read prior bytes (nil if create).
var priorBody []byte
priorExisted := false
if data, err := os.ReadFile(abs); err == nil {
priorBody = data
priorExisted = true
} else if !errors.Is(err, os.ErrNotExist) {
return WriteRecordResult{}, nil, fmt.Errorf("read prior: %w", err)
}
// Parse incoming body as a YAML map. Empty body is allowed
// (the schema validator catches required-field omissions, or
// the caller-side spec is permissive); we use an empty map.
bodyMap := map[string]any{}
if len(body) > 0 {
if err := yaml.Unmarshal(body, &bodyMap); err != nil {
return WriteRecordResult{}, []jsonschema.Error{{Path: "/", Message: "body is not valid YAML: " + err.Error()}}, nil
}
if bodyMap == nil {
bodyMap = map[string]any{}
}
}
// Strip client-supplied audit fields. The server is the sole
// authority for these; any value we'd accept here is forgeable.
stripAuditFields(bodyMap)
// Honor records: rule. If no rule matched the basename, fall
// through to a plain write (no stamping, no history) — this
// covers non-record YAML files like table.yaml that may share
// a directory with records.
if !hasRule {
if err := zddc.WriteAtomic(abs, body); err != nil {
return WriteRecordResult{}, nil, fmt.Errorf("write: %w", err)
}
return WriteRecordResult{FinalBody: body, Created: !priorExisted}, nil, nil
}
codes := chain.EffectiveFieldCodes()
// Inject field_defaults for keys the body omitted (so the
// stamped result is self-describing) and check locked: against
// any conflicting client values.
var verrs []jsonschema.Error
for k, want := range rule.FieldDefaults {
got, present := bodyMap[k]
if !present {
bodyMap[k] = want
continue
}
if locked := containsString(rule.Locked, k); locked {
gotStr := asString(got)
if gotStr != want {
verrs = append(verrs, jsonschema.Error{
Path: "/" + k,
Message: fmt.Sprintf("field is locked to %q in this folder; got %q", want, gotStr),
})
}
}
}
// Bind folder-derived fields (e.g. originator = party-folder
// name) before field-code validation and filename composition.
// The folder is authoritative, so this overwrites any client
// value; a wrong value still surfaces as a filename_format
// mismatch below on direct PUT.
if ferr := applyFolderFields(rule, dir, cfg.Root, bodyMap); ferr != nil {
return WriteRecordResult{}, nil, fmt.Errorf("folder fields: %w", ferr)
}
// Validate body values against field_codes (best-effort: only
// fields actually present in the body are checked; absent
// fields are someone else's concern — typically the form
// schema's required: list).
for k, code := range codes {
raw, ok := bodyMap[k]
if !ok {
continue
}
s := asString(raw)
if s == "" {
continue // empty/optional — schema enforces presence
}
if err := code.Validate(s); err != nil {
verrs = append(verrs, jsonschema.Error{
Path: "/" + k,
Message: err.Error(),
})
}
}
if len(verrs) > 0 {
return WriteRecordResult{}, verrs, nil
}
// Compose filename from body when filename_format is set, and
// verify the URL basename matches. Skipped when the rule has
// no format (SSR: identity is the parent folder name).
if rule.FilenameFormat != "" {
composed, cerr := composeFilename(rule.FilenameFormat, bodyMap)
if cerr != nil {
return WriteRecordResult{}, []jsonschema.Error{{Path: "/", Message: cerr.Error()}}, nil
}
ext := filepath.Ext(base)
composedWithExt := composed + ext
if composedWithExt != base {
return WriteRecordResult{}, []jsonschema.Error{{
Path: "/",
Message: fmt.Sprintf("filename mismatch: URL is %q, body composes to %q", base, composedWithExt),
}}, nil
}
}
// Stamp audit fields. On create: created_* and updated_* are
// both the current principal/timestamp; revision = 1. On
// update: preserve created_* (parse from priorBody), refresh
// updated_*, increment revision, set previous_sha = sha-prefix
// of priorBody.
now := time.Now().UTC().Format(time.RFC3339Nano)
if principalEmail == "" {
principalEmail = "anonymous"
}
priorAudit := parsePriorAudit(priorBody)
if !priorExisted {
bodyMap[auditFieldCreatedAt] = now
bodyMap[auditFieldCreatedBy] = principalEmail
bodyMap[auditFieldRevision] = 1
} else {
if priorAudit.createdAt != "" {
bodyMap[auditFieldCreatedAt] = priorAudit.createdAt
} else {
// Lazy migration: the prior file had no created_*
// stamp. Treat this write as the establishment of
// audit history — created and updated are the same
// principal/timestamp (we don't know who originally
// authored it).
bodyMap[auditFieldCreatedAt] = now
}
if priorAudit.createdBy != "" {
bodyMap[auditFieldCreatedBy] = priorAudit.createdBy
} else {
bodyMap[auditFieldCreatedBy] = principalEmail
}
bodyMap[auditFieldRevision] = priorAudit.revision + 1
bodyMap[auditFieldPreviousSha] = sha8(priorBody)
}
bodyMap[auditFieldUpdatedAt] = now
bodyMap[auditFieldUpdatedBy] = principalEmail
finalBody, err := yaml.Marshal(bodyMap)
if err != nil {
return WriteRecordResult{}, nil, fmt.Errorf("marshal: %w", err)
}
// Write history BEFORE live. If we crash after history but
// before live, the prior version is safe (still on disk under
// its history filename). The reverse order would lose the
// prior bytes if the live write succeeded but history failed.
// On a clean retry, the history filename is deterministic
// (timestamp+sha8 of priorBody) — rewriting it idempotently
// is harmless when the live write later succeeds.
if priorExisted {
histDir := filepath.Join(dir, HistoryDirName, stripExt(base))
if err := os.MkdirAll(histDir, 0o755); err != nil {
return WriteRecordResult{}, nil, fmt.Errorf("mkdir history: %w", err)
}
histName := now + "-" + sha8(priorBody) + filepath.Ext(base)
histPath := filepath.Join(histDir, histName)
if err := zddc.WriteAtomic(histPath, priorBody); err != nil {
return WriteRecordResult{}, nil, fmt.Errorf("write history: %w", err)
}
}
if err := zddc.WriteAtomic(abs, finalBody); err != nil {
return WriteRecordResult{}, nil, fmt.Errorf("write live: %w", err)
}
return WriteRecordResult{FinalBody: finalBody, Created: !priorExisted}, nil, nil
}
// priorAuditSnapshot is the minimum we need from a prior version's
// body to stamp the next revision: who created it (preserved
// forever) and what revision number it carried (so we can ++).
type priorAuditSnapshot struct {
createdAt string
createdBy string
revision int
}
func parsePriorAudit(body []byte) priorAuditSnapshot {
if len(body) == 0 {
return priorAuditSnapshot{}
}
m := map[string]any{}
if err := yaml.Unmarshal(body, &m); err != nil {
return priorAuditSnapshot{}
}
out := priorAuditSnapshot{}
if v, ok := m[auditFieldCreatedAt].(string); ok {
out.createdAt = v
}
if v, ok := m[auditFieldCreatedBy].(string); ok {
out.createdBy = v
}
switch v := m[auditFieldRevision].(type) {
case int:
out.revision = v
case int64:
out.revision = int(v)
case float64:
out.revision = int(v)
}
return out
}
func stripAuditFields(m map[string]any) {
delete(m, auditFieldCreatedAt)
delete(m, auditFieldCreatedBy)
delete(m, auditFieldUpdatedAt)
delete(m, auditFieldUpdatedBy)
delete(m, auditFieldRevision)
delete(m, auditFieldPreviousSha)
}
// composeFilename interpolates a filename_format template against
// the supplied body fields. Placeholders are {fieldname} for
// required segments and {fieldname?} for optional ones; an optional
// placeholder with an empty/missing body field is dropped along
// with one adjacent separator if both neighbors are static text.
//
// Example, format = "{originator}-{phase?}-{project}-{type}-{sequence}{suffix?}"
// with body = {originator: ACM, project: PRJ, type: SPC, sequence: 0001}
// (phase + suffix absent) yields "ACM-PRJ-SPC-0001".
//
// Adjacent-separator handling: the function recognises a "-" or "_"
// literal immediately preceding an optional placeholder and drops
// it together with the placeholder when the field is empty. Static
// text not adjacent to a placeholder is preserved as-is. A literal
// "{" or "}" must be escaped as "{{" / "}}" (currently unused —
// the embedded defaults don't need it).
func composeFilename(format string, body map[string]any) (string, error) {
var out strings.Builder
out.Grow(len(format))
i := 0
for i < len(format) {
c := format[i]
// Literal { or } escapes: {{ → {, }} → }.
if c == '{' && i+1 < len(format) && format[i+1] == '{' {
out.WriteByte('{')
i += 2
continue
}
if c == '}' && i+1 < len(format) && format[i+1] == '}' {
out.WriteByte('}')
i += 2
continue
}
if c != '{' {
out.WriteByte(c)
i++
continue
}
// Placeholder: scan to '}'.
end := strings.IndexByte(format[i+1:], '}')
if end == -1 {
return "", fmt.Errorf("filename_format: unterminated placeholder at offset %d", i)
}
name := format[i+1 : i+1+end]
i += end + 2 // past the '}'
optional := false
if strings.HasSuffix(name, "?") {
optional = true
name = name[:len(name)-1]
}
val := asString(body[name])
if val == "" {
if !optional {
return "", fmt.Errorf("filename_format: required field %q is missing or empty", name)
}
// Drop the trailing separator we just wrote, if any.
// For "A-{b?}-C" with b empty we want "A-C": dropping
// the preceding '-' here, then letting the next
// iteration emit the trailing '-' from the format, is
// exactly one connector between A and C. (Earlier
// versions of this code also skipped the leading
// separator, which double-elided.)
s := out.String()
if n := len(s); n > 0 && (s[n-1] == '-' || s[n-1] == '_') {
out.Reset()
out.WriteString(s[:n-1])
}
continue
}
out.WriteString(val)
}
return out.String(), nil
}
// applyFolderFields overwrites the body fields a rule binds to an
// ancestor folder name (RecordRule.FolderFields), making the folder
// the single source of truth for those components. recordDir is the
// directory the record file lives in; root bounds the upward walk.
// The folder name is authoritative: any client-supplied value is
// replaced, so a body can never disagree with the path it's filed
// under (and a mismatched URL still trips the filename_format check
// downstream on direct PUT). Returns an error only when the configured
// parent distance is negative or would escape the root.
func applyFolderFields(rule zddc.RecordRule, recordDir, root string, bodyMap map[string]any) error {
if len(rule.FolderFields) == 0 {
return nil
}
rootClean := filepath.Clean(root)
for field, dist := range rule.FolderFields {
if dist < 0 {
return fmt.Errorf("folder_fields[%q]: negative distance %d", field, dist)
}
src := filepath.Clean(recordDir)
for i := 0; i < dist; i++ {
src = filepath.Dir(src)
}
if src != rootClean && !strings.HasPrefix(src, rootClean+string(filepath.Separator)) {
return fmt.Errorf("folder_fields[%q]: distance %d escapes root", field, dist)
}
bodyMap[field] = filepath.Base(src)
}
return nil
}
// recordCreatePrep applies a record rule's field_defaults +
// folder_fields and, for row-based rules, the auto-assigned row
// sequence to dataMap, then composes the row's filename. dir is the
// slot directory the new row will land in. The returned fname carries
// the .yaml extension.
//
// It centralizes the dataMap mutation + name composition shared by the
// in-dir form create (serveFormCreate) and the project rollup
// (serveFormCreateRollup). Callers still own collision-checking and the
// WriteWithHistory call (which re-applies these same steps as the
// authority and enforces the composed name matches the path).
//
// composeErr is a 422-worthy validation error (body can't compose a
// filename); err is a 500-worthy internal error (folder-field
// misconfig, row-assign failure). Only call when rule.FilenameFormat
// is non-empty.
func recordCreatePrep(cfg config.Config, dir string, rule zddc.RecordRule, dataMap map[string]any) (fname string, composeErr *jsonschema.Error, err error) {
for k, want := range rule.FieldDefaults {
if _, present := dataMap[k]; !present {
dataMap[k] = want
}
}
if ferr := applyFolderFields(rule, dir, cfg.Root, dataMap); ferr != nil {
return "", nil, ferr
}
if rule.RowField != "" {
rowVal, rerr := AssignNextRow(dir, rule.RowField, rule.RowScopeFields, dataMap)
if rerr != nil {
return "", nil, rerr
}
dataMap[rule.RowField] = rowVal
}
composed, cerr := composeFilename(rule.FilenameFormat, dataMap)
if cerr != nil {
return "", &jsonschema.Error{Path: "/", Message: cerr.Error()}, nil
}
return composed + ".yaml", nil, nil
}
// AssignNextRow finds the next free row sequence within the
// row-scope group identified by scopeFields. Used by POST-create
// handlers (rsk row creation) before invoking WriteWithHistory.
// Returns the zero-padded string value to inject into bodyMap[rowField].
//
// Width is fixed at 3 (covers up to 999 rows per table). Operators
// who need more declare a per-deployment field_codes:row pattern;
// the width here is for the auto-assign output, not for parsing
// (which uses the matched pattern from the cascade).
func AssignNextRow(dir, rowField string, scopeFields []string, body map[string]any) (string, error) {
if rowField == "" {
return "", fmt.Errorf("row_field is empty")
}
entries, err := os.ReadDir(dir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return "001", nil
}
return "", err
}
max := 0
rowRe := regexp.MustCompile(`^[0-9]+$`)
for _, ent := range entries {
if ent.IsDir() {
continue
}
name := ent.Name()
if !strings.HasSuffix(name, filepath.Ext(name)) {
continue
}
path := filepath.Join(dir, name)
data, err := os.ReadFile(path)
if err != nil {
continue
}
other := map[string]any{}
if err := yaml.Unmarshal(data, &other); err != nil {
continue
}
// Same scope group?
sameGroup := true
for _, f := range scopeFields {
if asString(other[f]) != asString(body[f]) {
sameGroup = false
break
}
}
if !sameGroup {
continue
}
v := asString(other[rowField])
if !rowRe.MatchString(v) {
continue
}
n := atoiSafe(v)
if n > max {
max = n
}
}
return fmt.Sprintf("%03d", max+1), nil
}
// ServeHistoryList responds to GET .yaml?history=1 with the
// list of prior revisions archived under .history//. The
// caller has already evaluated ACL against the live record (read
// permission on the parent dir gates history visibility too — if
// you can read the current state you can read its history).
//
// Returns 404 when abs doesn't exist or isn't a record (the caller
// should rely on the live record's GET 404 path instead of leaking
// existence here, but defense in depth costs nothing).
func ServeHistoryList(w http.ResponseWriter, r *http.Request, abs string) {
if !isRecordPathForHistory(abs) {
http.NotFound(w, r)
return
}
if _, err := os.Stat(abs); err != nil {
if errors.Is(err, os.ErrNotExist) {
// Record file gone; the caller's normal 404 path
// suppresses existence-leak, so we mirror that.
http.NotFound(w, r)
return
}
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
entries, err := ListHistory(abs)
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("X-ZDDC-Source", "history-list")
// json.NewEncoder for streaming; sort already happened in
// ListHistory.
enc := json.NewEncoder(w)
if err := enc.Encode(entries); err != nil {
// Body already partially flushed at this point; nothing to do.
return
}
}
// isRecordPathForHistory mirrors isRecordPath but lives in this file
// so the history-list handler doesn't need to import its caller's
// internal helper. Keep the two in sync — if one accepts a new
// extension the other should too.
func isRecordPathForHistory(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
}
if strings.HasSuffix(base, ".table.yaml") || strings.HasSuffix(base, ".form.yaml") {
return false
}
return true
}
// HistoryEntry describes one prior revision of a record, as listed
// by ServeHistoryList.
type HistoryEntry struct {
Revision int `json:"revision"`
Ts string `json:"ts"`
By string `json:"by"`
Sha8 string `json:"sha"`
Path string `json:"path"`
}
// ListHistory walks the .history// directory adjacent to abs
// and returns one HistoryEntry per archived revision, sorted newest
// first. Empty list when the dir doesn't exist (e.g. record never
// updated).
//
// Filename format: -.. Author/revision are
// read from the YAML body's audit fields — those describe the
// archived bytes' provenance.
func ListHistory(abs string) ([]HistoryEntry, error) {
dir := filepath.Dir(abs)
base := filepath.Base(abs)
histDir := filepath.Join(dir, HistoryDirName, stripExt(base))
ents, err := os.ReadDir(histDir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
return nil, err
}
out := make([]HistoryEntry, 0, len(ents))
for _, e := range ents {
if e.IsDir() {
continue
}
name := e.Name()
// Expected shape: -.. Parse from the right
// to be lenient about timestamps that contain '-'.
ext := filepath.Ext(name)
stem := strings.TrimSuffix(name, ext)
idx := strings.LastIndexByte(stem, '-')
if idx < 0 {
continue
}
ts := stem[:idx]
sha := stem[idx+1:]
entry := HistoryEntry{Ts: ts, Sha8: sha, Path: filepath.Join(HistoryDirName, stripExt(base), name)}
// Pull author + revision from the archived body.
if data, err := os.ReadFile(filepath.Join(histDir, name)); err == nil {
snap := parsePriorAudit(data)
entry.Revision = snap.revision
entry.By = snap.createdBy
// updated_by is more informative when present.
m := map[string]any{}
if err := yaml.Unmarshal(data, &m); err == nil {
if v, ok := m[auditFieldUpdatedBy].(string); ok && v != "" {
entry.By = v
}
}
}
out = append(out, entry)
}
sort.Slice(out, func(i, j int) bool { return out[i].Ts > out[j].Ts })
return out, nil
}
// augmentSchemaFromCascade mutates schema in place to inject
// cascade-resolved field_codes and records:-rule constraints. For
// every property whose name matches a field-code key, the relevant
// enum/pattern/labels are injected. For every record-rule's locked
// field, the corresponding property is marked readOnly. For every
// field_default, the corresponding property's Default is set if
// absent.
//
// gateDir is the directory the cascade was resolved at — needed
// only to pick the right records: rule when multiple patterns
// could match. The current cascade interface gives us the chain
// already; we pull a single "*.yaml" representative rule (matching
// the create-time behaviour in serveFormCreateRollup).
//
// Mutates the input schema. No-op when schema is nil.
func augmentSchemaFromCascade(schema *jsonschema.Schema, chain zddc.PolicyChain, gateDir string) {
if schema == nil || schema.Properties == nil {
return
}
codes := chain.EffectiveFieldCodes()
for name, prop := range schema.Properties {
if code, ok := codes[name]; ok {
switch code.Kind {
case zddc.FieldCodeEnum:
// Populate Enum with the code keys (sorted for
// deterministic order). Labels carries the
// human-readable display strings.
keys := make([]string, 0, len(code.Codes))
for k := range code.Codes {
keys = append(keys, k)
}
sort.Strings(keys)
if len(prop.Enum) == 0 {
prop.Enum = make([]any, len(keys))
for i, k := range keys {
prop.Enum[i] = k
}
}
if prop.Labels == nil && len(code.Codes) > 0 {
prop.Labels = make(map[string]string, len(code.Codes))
for k, v := range code.Codes {
prop.Labels[k] = v
}
}
case zddc.FieldCodePattern:
if prop.Pattern == "" {
prop.Pattern = code.Pattern
}
case zddc.FieldCodeFree:
// No constraint to inject; description is the
// only field and the operator can author it
// directly in the form spec.
}
}
}
// Apply the matched records:-rule's readOnly + default to
// matching properties. We probe with "*.yaml" — the records
// entries shipped in the embedded defaults all match that
// glob; operator schemas with literal-keyed rules would still
// be honoured by serveFormCreateRollup but won't be reflected
// in the form-render augmentation here.
if _, rule, ok := chain.EffectiveRecordRule("placeholder.yaml"); ok {
for _, name := range rule.Locked {
if prop, present := schema.Properties[name]; present {
prop.ReadOnly = true
}
}
for name, val := range rule.FieldDefaults {
if prop, present := schema.Properties[name]; present {
if prop.Default == nil {
prop.Default = val
}
}
}
// Folder-bound fields: the folder is authoritative, so render
// them read-only and pre-fill the value derived from gateDir
// (the directory the cascade was resolved at, == the record
// dir for per-party forms). Rollup forms resolve no rule here
// — their virtual path carries no records: entry — so the
// rollup schema marks originator read-only statically instead.
for name, dist := range rule.FolderFields {
prop, present := schema.Properties[name]
if !present || dist < 0 {
continue
}
prop.ReadOnly = true
src := filepath.Clean(gateDir)
for i := 0; i < dist; i++ {
src = filepath.Dir(src)
}
if prop.Default == nil {
prop.Default = filepath.Base(src)
}
}
}
}
// ---- small helpers ----
func sha8(data []byte) string {
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])[:8]
}
func stripExt(name string) string {
return strings.TrimSuffix(name, filepath.Ext(name))
}
func containsString(haystack []string, needle string) bool {
for _, s := range haystack {
if s == needle {
return true
}
}
return false
}
func asString(v any) string {
switch s := v.(type) {
case string:
return s
case nil:
return ""
case int:
return fmt.Sprintf("%d", s)
case int64:
return fmt.Sprintf("%d", s)
case float64:
// Strip trailing .0 for the common integer-in-JSON case.
if s == float64(int64(s)) {
return fmt.Sprintf("%d", int64(s))
}
return fmt.Sprintf("%v", s)
default:
return fmt.Sprintf("%v", s)
}
}
func atoiSafe(s string) int {
n := 0
for _, c := range s {
if c < '0' || c > '9' {
return 0
}
n = n*10 + int(c-'0')
}
return n
}
// ─── Markdown / text edit-history ────────────────────────────────────────
//
// History-enabled text files (a `history: true` .zddc subtree — see
// zddc.PolicyChain.EffectiveHistory) drop one self-describing snapshot per
// save under /.history//:
//
// .history//-. one file per save
//
// The filename IS the audit record: is a colon-free UTC timestamp
// (valid on SMB / Azure Files) and is the authenticated principal.
// No sidecar log, no content hashing — listing the directory is the
// history. Reverting copies an old snapshot's bytes onto the live file
// (which then records as a fresh save). Timestamp + email are stamped
// server-side, never client-supplied. Snapshots are kept forever: a doc
// rename renames its / folder (see the move handler); a delete or a
// move out of the working dir leaves the history behind.
// mdStampLayout formats the save time WITHOUT colons or hyphens so the
// name stays valid on SMB/Windows shares AND splits cleanly on the first
// '-' into -. A literal "Z" is appended separately (a bare "Z"
// is not a Go time token). The fixed width sorts lexically by time.
const mdStampLayout = "20060102T150405.000"
// MdHistoryEntry is one saved version of a history-tracked text file.
type MdHistoryEntry struct {
Ts string `json:"ts"` // RFC3339 UTC of the save (parsed from the filename)
By string `json:"by"` // authoring principal email ("unknown" if pre-history)
ID string `json:"id"` // the snapshot filename — opaque version id for ?history=
Bytes int64 `json:"bytes"` // size of this version
Current bool `json:"current,omitempty"` // derived by ListMdHistory: the version matching the live file
}
// IsTextHistoryCandidate reports whether abs is eligible for text edit-
// history at its location: its basename matches the effective history globs
// from the .zddc cascade (default "*.md", widen per-deployment via the
// `history_globs:` key). fsRoot is the server root for cascade resolution.
func IsTextHistoryCandidate(fsRoot, abs string) bool {
return matchHistoryGlobs(zddc.HistoryGlobsAt(fsRoot, filepath.Dir(abs)), filepath.Base(abs))
}
// matchHistoryGlobs reports whether base matches any of the globs
// (case-insensitively, so .MD matches *.md).
func matchHistoryGlobs(globs []string, base string) bool {
lb := strings.ToLower(base)
for _, g := range globs {
if ok, _ := filepath.Match(strings.ToLower(g), lb); ok {
return true
}
}
return false
}
func mdHistoryDir(abs string) string {
return filepath.Join(filepath.Dir(abs), HistoryDirName, stripExt(filepath.Base(abs)))
}
// mdStamp renders t as the colon-free snapshot timestamp with the trailing
// Z, e.g. 20260602T143000.123Z.
func mdStamp(t time.Time) string {
return t.UTC().Format(mdStampLayout) + "Z"
}
// sanitizeForFilename keeps a principal email filesystem-safe. Emails are
// already SMB-safe (@ . - _ +); only a path separator or control char would
// be a problem, so collapse anything outside that set to '_'. Empty → the
// "unknown" sentinel used for pre-history seed snapshots.
func sanitizeForFilename(s string) string {
if s == "" {
return "unknown"
}
var b strings.Builder
for _, r := range s {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9',
r == '@', r == '.', r == '_', r == '+', r == '-':
b.WriteRune(r)
default:
b.WriteRune('_')
}
}
return b.String()
}
// parseHistoryName splits a snapshot filename "-." into a
// display timestamp (RFC3339) and the author email. ok is false for names
// that don't match the scheme (so foreign files in the dir are ignored).
// The ts field has no '-', so the first '-' is the ts/email boundary; the
// email may itself contain '-' (e.g. a hyphenated domain).
func parseHistoryName(name string) (tsRFC3339, email string, ok bool) {
ext := filepath.Ext(name)
stem := strings.TrimSuffix(name, ext)
dash := strings.IndexByte(stem, '-')
if dash <= 0 || dash == len(stem)-1 {
return "", "", false
}
tsRaw := strings.TrimSuffix(stem[:dash], "Z")
t, err := time.Parse(mdStampLayout, tsRaw)
if err != nil {
return "", "", false
}
return t.UTC().Format(time.RFC3339), stem[dash+1:], true
}
// WriteTextWithHistory drops a snapshot of the new content into
// .history//-., then writes the live file. A save
// byte-identical to the live file is a no-op (no snapshot, no rewrite). A
// file that pre-existed history enablement is lazy-seeded: its current
// bytes are captured as an origin snapshot (stamped with the file's mtime,
// author "unknown") before the new one. The snapshot is written BEFORE the
// live file so a crash can't lose a version the live write superseded.
func WriteTextWithHistory(abs string, body []byte, principalEmail string) error {
histDir := mdHistoryDir(abs)
ext := filepath.Ext(abs)
// Prior live content (nil on create).
var prior []byte
var priorMtime time.Time
if info, err := os.Stat(abs); err == nil && !info.IsDir() {
if data, rerr := os.ReadFile(abs); rerr == nil {
prior = data
priorMtime = info.ModTime().UTC()
}
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
// No-op: identical to the live file → nothing to record or rewrite.
if prior != nil && bytes.Equal(prior, body) {
return nil
}
if err := os.MkdirAll(histDir, 0o755); err != nil {
return fmt.Errorf("mkdir history: %w", err)
}
// Lazy-seed the pre-history origin version (no snapshots yet but the
// file already has content).
if prior != nil && historyDirEmpty(histDir) {
seedAt := priorMtime
if seedAt.IsZero() {
seedAt = time.Now().UTC()
}
if err := writeHistorySnapshot(histDir, seedAt, "", ext, prior); err != nil {
return err
}
}
if err := writeHistorySnapshot(histDir, time.Now().UTC(), principalEmail, ext, body); err != nil {
return err
}
return zddc.WriteAtomic(abs, body)
}
// writeHistorySnapshot writes data to /-. On a
// name collision (same millisecond + author) it steps the timestamp
// forward until a free name is found, so each save keeps a distinct file.
func writeHistorySnapshot(histDir string, t time.Time, email, ext string, data []byte) error {
who := sanitizeForFilename(email)
for i := 0; i < 1000; i++ {
p := filepath.Join(histDir, mdStamp(t)+"-"+who+ext)
if _, err := os.Stat(p); errors.Is(err, os.ErrNotExist) {
return zddc.WriteAtomic(p, data)
} else if err != nil {
return err
}
t = t.Add(time.Millisecond)
}
return fmt.Errorf("history: no free snapshot name in %s", histDir)
}
// historyDirEmpty reports whether histDir holds no snapshot files (missing
// dir counts as empty).
func historyDirEmpty(histDir string) bool {
entries, err := os.ReadDir(histDir)
if err != nil {
return true
}
for _, e := range entries {
if !e.IsDir() {
return false
}
}
return true
}
// ListMdHistory returns the saved versions of abs, newest first, with
// Current set on the version whose bytes match the live file.
func ListMdHistory(abs string) ([]MdHistoryEntry, error) {
histDir := mdHistoryDir(abs)
dirEntries, err := os.ReadDir(histDir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return []MdHistoryEntry{}, nil
}
return nil, err
}
out := []MdHistoryEntry{}
for _, de := range dirEntries {
if de.IsDir() {
continue
}
ts, by, ok := parseHistoryName(de.Name())
if !ok {
continue
}
var size int64
if info, ierr := de.Info(); ierr == nil {
size = info.Size()
}
out = append(out, MdHistoryEntry{Ts: ts, By: by, ID: de.Name(), Bytes: size})
}
// Newest first — the id (filename) sorts lexically by its ts prefix.
sort.SliceStable(out, func(i, j int) bool { return out[i].ID > out[j].ID })
// Mark the newest snapshot whose bytes match the live file as current.
// Size-gate before reading so we don't slurp every version.
if live, lerr := os.ReadFile(abs); lerr == nil {
for i := range out {
if out[i].Bytes != int64(len(live)) {
continue
}
if data, rerr := os.ReadFile(filepath.Join(histDir, out[i].ID)); rerr == nil && bytes.Equal(data, live) {
out[i].Current = true
break
}
}
}
return out, nil
}
// ServeTextHistory dispatches GET ?history=... for history-eligible
// text files: `?history=1` (or empty / `list`) returns the version list as
// JSON; `?history=` returns that snapshot's raw bytes. ACL on the live
// file has already been checked by the caller; fsRoot resolves the cascade
// for the file-type (history_globs) check.
func ServeTextHistory(w http.ResponseWriter, r *http.Request, fsRoot, abs, version string) {
if !IsTextHistoryCandidate(fsRoot, abs) {
http.NotFound(w, r)
return
}
if version == "" || version == "1" || version == "list" {
entries, err := ListMdHistory(abs)
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("X-ZDDC-Source", "history-list")
_ = json.NewEncoder(w).Encode(entries)
return
}
// version is a snapshot filename. Reject anything that could escape the
// history dir, then require it to resolve to a file strictly inside it.
if version == "." || version == ".." || strings.ContainsAny(version, "/\\") || strings.Contains(version, "..") {
http.Error(w, "Bad Request — invalid version id", http.StatusBadRequest)
return
}
histDir := mdHistoryDir(abs)
snap := filepath.Join(histDir, version)
if snap != filepath.Clean(snap) || !strings.HasPrefix(snap, histDir+string(filepath.Separator)) {
http.Error(w, "Bad Request — invalid version id", http.StatusBadRequest)
return
}
data, err := os.ReadFile(snap)
if err != nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/markdown; charset=utf-8")
w.Header().Set("X-ZDDC-Source", "history-version")
w.Header().Set("X-ZDDC-History-Version", version)
if r.Method == http.MethodHead {
w.WriteHeader(http.StatusOK)
return
}
_, _ = w.Write(data)
}