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