diff --git a/AGENTS.md b/AGENTS.md index a1ee9d6..f24b1e4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,7 +14,7 @@ ./build alpha # cut alpha (cascades nothing) ./build beta # cut beta (cascades alpha → beta) ./build release # cut stable, coordinated next version - # (cascades alpha + beta → new stable; tags all six) + # (cascades alpha + beta → new stable; tags all seven) ./build release 1.2.0 # cut stable at explicit version ./build help @@ -27,7 +27,7 @@ ./deploy --releases # only dist/release-output/ → /srv/zddc/releases/ # Single-tool dev build for testing (does NOT touch dist/release-output/): -sh tool/build.sh # archive|transmittal|classifier|mdedit|landing +sh tool/build.sh # archive|transmittal|classifier|mdedit|landing|form # Single-tool release (rare; prefer ./build alpha|beta|release so versions # don't drift between tools). Same flag form as before. @@ -38,7 +38,7 @@ sh tool/build.sh --release [|alpha|beta] npm test # Test single tool -npx playwright test tool # archive | transmittal | classifier | mdedit +npx playwright test tool # archive | transmittal | classifier | mdedit | form-safety # Dev server (cache-busting HTTP, on port 8000) ./dev-server start @@ -60,7 +60,7 @@ because the bundle is complete, dangling-link errors mean a real bug. ## Architecture -Five independent single-file HTML tools (`archive`, `transmittal`, `classifier`, `mdedit`, `landing`). Each compiles to one self-contained `.html` in `dist/` with all CSS and JS inlined — the first four name their output `dist/tool.html`; `landing` writes `dist/index.html` (it's served at `/` by `zddc-server`). Tools share a small set of canonical helpers in `shared/` (filename parsing, ZDDC filter UI, theme, help) — see "Shared modules" below. +Six independent single-file HTML tools (`archive`, `transmittal`, `classifier`, `mdedit`, `landing`, `form`). Each compiles to one self-contained `.html` in `dist/` with all CSS and JS inlined — most name their output `dist/tool.html`; `landing` writes `dist/index.html` (served at `/` by `zddc-server`). Tools share a small set of canonical helpers in `shared/` (filename parsing, ZDDC filter UI, theme, help) — see "Shared modules" below. The sixth tool, `form`, is the schema-driven renderer used by zddc-server's form-data system; see "Form-data system" below. ``` tool/ @@ -134,7 +134,7 @@ Included as the **first** positional arg to every tool's `concat_files` CSS call - Build scripts use **POSIX sh** (`#!/bin/sh` with `set -eu`), not bash. - `concat_files` accepts **positional args only** (not array names). - `awk` processes `template.html`, replacing `{{PLACEHOLDER}}` markers and stripping CDN ` + + + + diff --git a/mdedit/dist/mdedit.html b/mdedit/dist/mdedit.html index 9e3488f..9a55abc 100644 --- a/mdedit/dist/mdedit.html +++ b/mdedit/dist/mdedit.html @@ -1774,7 +1774,7 @@ body.help-open .app-header {
ZDDC Markdown - v0.0.9-alpha · 2026-05-02 14:02:00 · 76e1e78-dirty + v0.0.9-alpha · 2026-05-03 01:04:52 · c099676-dirty
diff --git a/playwright.config.js b/playwright.config.js index 339bbd4..6df9e5a 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -51,6 +51,10 @@ export default defineConfig({ name: 'zddc', testMatch: 'zddc.spec.js', }, + { + name: 'form-safety', + testMatch: 'form-safety.spec.js', + }, { name: 'zddc-filter', testMatch: 'zddc-filter.spec.js', @@ -65,3 +69,4 @@ export default defineConfig({ }, ], }); + diff --git a/shared/build-lib.sh b/shared/build-lib.sh index a9f32e1..20a2a87 100755 --- a/shared/build-lib.sh +++ b/shared/build-lib.sh @@ -222,7 +222,7 @@ _emit_build_label_sidecar() { # Tools that participate in the lockstep release. Source of truth — used # by helpers that enumerate "all release artifacts" (matrix render, # coordinated next-stable, channel-link verifier). -ZDDC_RELEASE_TOOLS="archive transmittal classifier mdedit landing zddc-server" +ZDDC_RELEASE_TOOLS="archive transmittal classifier mdedit landing form zddc-server" # Compute the next-stable target for a single tool — patch-bump of its own # latest -vX.Y.Z tag. Used by compute_build_label so a tool's @@ -653,7 +653,7 @@ verify_channel_links() { _missing=0 _verified=0 - for _t in archive transmittal classifier mdedit landing; do + for _t in archive transmittal classifier mdedit landing form; do for _ch in stable beta alpha; do _f="$_rdir/${_t}_${_ch}.html" if [ -e "$_f" ]; then diff --git a/tests/form-safety.spec.js b/tests/form-safety.spec.js new file mode 100644 index 0000000..52fd22f --- /dev/null +++ b/tests/form-safety.spec.js @@ -0,0 +1,250 @@ +import { test, expect } from '@playwright/test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +const HTML_PATH = path.resolve('form/dist/form.html'); +const HTML_RAW = fs.readFileSync(HTML_PATH, 'utf8'); + +const SAFETY_SCHEMA = { + type: 'object', + required: ['date', 'location'], + additionalProperties: false, + properties: { + date: { type: 'string', format: 'date' }, + location: { type: 'string', enum: ['Site A', 'Site B', 'Site C'] }, + hazards: { + type: 'array', + items: { + type: 'object', + required: ['kind', 'severity'], + properties: { + kind: { type: 'string' }, + severity: { type: 'integer', minimum: 1, maximum: 5 }, + notes: { type: 'string' }, + }, + }, + }, + additionalNotes: { type: 'string' }, + }, +}; + +const SAFETY_UI = { + location: { 'ui:widget': 'radio' }, + hazards: { 'ui:options': { addable: true, removable: true } }, + additionalNotes: { 'ui:widget': 'textarea' }, +}; + +// Inject a complete form-context into the page before form bootstraps. +// Writes a patched copy of form.html to a temp file and navigates via +// file:// — page.setContent's about:blank origin doesn't expose +// localStorage, which trips up shared/theme.js. page.route can't intercept +// file://, so this is the cleanest path. The form is fully self-contained, +// so the temp file works without relative-resource resolution. +async function loadFormWithContext(page, context) { + const ctxJson = JSON.stringify(context).replace(/<\//g, '<\\/'); + const replacement = ``; + const patched = HTML_RAW.replace( + / + + + + diff --git a/zddc/internal/handler/formhandler.go b/zddc/internal/handler/formhandler.go new file mode 100644 index 0000000..9deb915 --- /dev/null +++ b/zddc/internal/handler/formhandler.go @@ -0,0 +1,578 @@ +// Package handler — formhandler.go: the form-data system endpoints. +// +// URL conventions (the form always POSTs to the same URL it was GET'd from; +// the server strips ".html" and routes by what's underneath): +// +// GET //.form.html → render empty form +// POST //.form.html → create new submission → 201 + Location +// GET ///.yaml.html → render form pre-filled from .yaml +// POST ///.yaml.html → validate + overwrite that submission → 200 +// +// Direct GET of the raw .yaml (data) and .form.yaml (spec) continues through +// the existing static-file path; only the .html suffix is hijacked here. +// +// Storage layout: a form named "safety" lives at /safety.form.yaml; +// submissions go to /safety/-.yaml. The +// submissions folder is created lazily on first POST. ACL via the existing +// .zddc cascade — submit-rights = path-write-rights at the submissions +// directory. +package handler + +import ( + _ "embed" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "os" + "path/filepath" + "strconv" + "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" +) + +//go:embed form.html +var embeddedFormHTML []byte + +// FormSpec is the YAML envelope of a .form.yaml file. +// +// v0 fields: Title, Description, Schema, UI. Mode is reserved for the v1 +// file-as-truth introduction (default form-as-truth = empty / "form-as-truth"). +// Unknown YAML keys are ignored — this struct is the source of truth for the +// supported form-spec vocabulary. +type FormSpec struct { + Title string `yaml:"title"` + Description string `yaml:"description"` + Schema *jsonschema.Schema `yaml:"schema"` + UI map[string]interface{} `yaml:"ui"` + Mode string `yaml:"mode"` +} + +// formContext is the JSON object the server injects into the form HTML. +// The renderer (form/js/context.js) reads this from #form-context. +type formContext struct { + Title string `json:"title,omitempty"` + Schema *jsonschema.Schema `json:"schema"` + UI map[string]interface{} `json:"ui,omitempty"` + Data interface{} `json:"data,omitempty"` + SubmitURL string `json:"submitUrl"` + Errors []jsonschema.Error `json:"errors,omitempty"` +} + +// FormRequest describes a recognized form-system request. +type FormRequest struct { + // Kind is one of: "render-empty", "create", "render-edit", "update". + Kind string + // SpecPath is the absolute filesystem path to the .form.yaml. + SpecPath string + // DataPath is the absolute filesystem path to the data .yaml; empty for + // render-empty / create. + DataPath string + // SubmitURL is the URL the form should POST back to (the server-injected + // "submit to my own URL" value). + SubmitURL string +} + +// RecognizeFormRequest classifies r as a form-system request, or returns nil +// if it falls through to static file serving. Form-spec existence on disk is +// required: a *.form.html URL with no corresponding *.form.yaml is not a +// form request. +// +// Methods other than GET / POST return nil (HEAD / OPTIONS pass through to +// the catch-all so the standard handlers respond). +func RecognizeFormRequest(fsRoot, method, urlPath string) *FormRequest { + if method != http.MethodGet && method != http.MethodPost { + return nil + } + if !strings.HasSuffix(urlPath, ".html") { + return nil + } + underlying := strings.TrimSuffix(urlPath, ".html") + + // Form-spec URL: .form.html → spec at .form.yaml. + // Data URL: /.yaml.html → underlying ends in .yaml → spec at + // .form.yaml at the parent's parent. + if strings.HasSuffix(underlying, ".form") { + // .form.html — empty form / create. + specRel := filepath.Clean(filepath.FromSlash(strings.TrimPrefix(underlying, "/"))) + ".yaml" + specAbs := filepath.Join(fsRoot, specRel) + if !strings.HasPrefix(specAbs, fsRoot+string(filepath.Separator)) && specAbs != fsRoot { + return nil + } + if !fileExists(specAbs) { + return nil + } + kind := "render-empty" + if method == http.MethodPost { + kind = "create" + } + return &FormRequest{ + Kind: kind, + SpecPath: specAbs, + SubmitURL: urlPath, + } + } + + if strings.HasSuffix(underlying, ".yaml") { + // /.yaml.html — re-edit / update. + dataRel := filepath.Clean(filepath.FromSlash(strings.TrimPrefix(underlying, "/"))) + dataAbs := filepath.Join(fsRoot, dataRel) + if !strings.HasPrefix(dataAbs, fsRoot+string(filepath.Separator)) && dataAbs != fsRoot { + return nil + } + // Spec lives at the parent's parent: //.yaml → + // /.form.yaml. + parentDir := filepath.Dir(dataAbs) + formName := filepath.Base(parentDir) + grandparent := filepath.Dir(parentDir) + specPath := filepath.Join(grandparent, formName+".form.yaml") + if !fileExists(specPath) { + return nil + } + kind := "render-edit" + if method == http.MethodPost { + kind = "update" + } + return &FormRequest{ + Kind: kind, + SpecPath: specPath, + DataPath: dataAbs, + SubmitURL: urlPath, + } + } + + return nil +} + +// ServeForm dispatches a recognized form request to render or write logic. +// The catch-all dispatch in zddc-server/main.go calls this whenever +// RecognizeFormRequest returns non-nil. +func ServeForm(cfg config.Config, req *FormRequest, w http.ResponseWriter, r *http.Request) { + switch req.Kind { + case "render-empty": + serveFormRender(cfg, req, w, r, nil) + case "render-edit": + serveFormRender(cfg, req, w, r, nil) + case "create": + serveFormCreate(cfg, req, w, r) + case "update": + serveFormUpdate(cfg, req, w, r) + default: + http.Error(w, "unknown form request kind", http.StatusInternalServerError) + } +} + +// serveFormRender handles GET requests for both empty and pre-filled forms. +// validationErrs is non-nil only when re-rendering after a POST→422 (not used +// in v0 — POST returns JSON 422 and the client patches errors into the live +// form via JS). +func serveFormRender(cfg config.Config, req *FormRequest, w http.ResponseWriter, r *http.Request, validationErrs []jsonschema.Error) { + email := EmailFromContext(r) + + // ACL: read-rights at the directory holding the spec (and, for edits, at + // the directory holding the data file). Cascade chain is the same for + // every entity in the same directory — a single check covers both. + gateDir := filepath.Dir(req.SpecPath) + if req.DataPath != "" { + gateDir = filepath.Dir(req.DataPath) + } + chain, err := zddc.EffectivePolicy(cfg.Root, gateDir) + if err != nil { + slog.Warn("form: policy error", "path", gateDir, "err", err) + } + if !zddc.AllowedWithChain(chain, email) { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + + if len(embeddedFormHTML) == 0 { + http.Error(w, "form renderer not built into this binary", http.StatusServiceUnavailable) + return + } + + spec, err := loadFormSpec(req.SpecPath) + if err != nil { + slog.Warn("form: spec parse error", "path", req.SpecPath, "err", err) + http.Error(w, "form spec error: "+err.Error(), http.StatusInternalServerError) + return + } + + var data interface{} + if req.DataPath != "" { + if !fileExists(req.DataPath) { + http.NotFound(w, r) + return + } + raw, err := os.ReadFile(req.DataPath) + if err != nil { + http.Error(w, "read submission: "+err.Error(), http.StatusInternalServerError) + return + } + if err := yaml.Unmarshal(raw, &data); err != nil { + http.Error(w, "parse submission: "+err.Error(), http.StatusInternalServerError) + return + } + data = normalizeYAMLForJSON(data) + } + + ctx := formContext{ + Title: spec.Title, + Schema: spec.Schema, + UI: spec.UI, + Data: data, + SubmitURL: req.SubmitURL, + Errors: validationErrs, + } + + html, err := injectFormContext(embeddedFormHTML, ctx) + if err != nil { + http.Error(w, "render: "+err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + _, _ = w.Write(html) +} + +// serveFormCreate handles POST to .form.html — creates a new submission. +func serveFormCreate(cfg config.Config, req *FormRequest, w http.ResponseWriter, r *http.Request) { + email := EmailFromContext(r) + if email == "" { + http.Error(w, "authentication required", http.StatusUnauthorized) + return + } + + formName := strings.TrimSuffix(filepath.Base(req.SpecPath), ".form.yaml") + specDir := filepath.Dir(req.SpecPath) + submissionsDir := filepath.Join(specDir, formName) + + // ACL: write-rights at submissions dir. The dir may not exist yet; the + // cascade chain falls back to the parent. + gateDir := submissionsDir + if !fileExists(submissionsDir) { + gateDir = specDir + } + chain, err := zddc.EffectivePolicy(cfg.Root, gateDir) + if err != nil { + slog.Warn("form: policy error", "path", gateDir, "err", err) + } + if !zddc.AllowedWithChain(chain, email) { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + + data, err := decodeRequestData(r) + if err != nil { + http.Error(w, "request body: "+err.Error(), http.StatusBadRequest) + return + } + + spec, err := loadFormSpec(req.SpecPath) + if err != nil { + http.Error(w, "form spec error: "+err.Error(), http.StatusInternalServerError) + return + } + + if errs := jsonschema.Validate(spec.Schema, data); len(errs) > 0 { + writeValidationErrors(w, errs) + return + } + + if err := os.MkdirAll(submissionsDir, 0o755); err != nil { + http.Error(w, "ensure submissions dir: "+err.Error(), http.StatusInternalServerError) + return + } + + dateStr := time.Now().UTC().Format("2006-01-02") + emailSan := sanitizeEmail(email) + base := dateStr + "-" + emailSan + target, fname, ok := pickAvailableFilename(submissionsDir, base, ".yaml") + if !ok { + http.Error(w, "could not pick a free filename (>100 collisions)", http.StatusConflict) + return + } + + yamlBytes, err := yaml.Marshal(data) + if err != nil { + http.Error(w, "marshal yaml: "+err.Error(), http.StatusInternalServerError) + return + } + if err := zddc.WriteAtomic(target, yamlBytes); err != nil { + http.Error(w, "write: "+err.Error(), http.StatusInternalServerError) + return + } + + // Capability URL: the path to the new submission file. The renderer + // appends ".html" to navigate back to the form-rendered view of the just- + // saved data. + relPath, err := filepath.Rel(cfg.Root, target) + if err != nil { + slog.Warn("form: rel path error", "root", cfg.Root, "target", target, "err", err) + http.Error(w, "post-write: "+err.Error(), http.StatusInternalServerError) + return + } + capURL := "/" + filepath.ToSlash(relPath) + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Location", capURL) + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(map[string]string{ + "location": capURL, + "filename": fname, + }) +} + +// serveFormUpdate handles POST to /.yaml.html — overwrites an +// existing submission after re-validating against the form spec. +func serveFormUpdate(cfg config.Config, req *FormRequest, w http.ResponseWriter, r *http.Request) { + email := EmailFromContext(r) + if email == "" { + http.Error(w, "authentication required", http.StatusUnauthorized) + return + } + if !fileExists(req.DataPath) { + http.NotFound(w, r) + return + } + + chain, err := zddc.EffectivePolicy(cfg.Root, filepath.Dir(req.DataPath)) + if err != nil { + slog.Warn("form: policy error", "path", req.DataPath, "err", err) + } + if !zddc.AllowedWithChain(chain, email) { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + + data, err := decodeRequestData(r) + if err != nil { + http.Error(w, "request body: "+err.Error(), http.StatusBadRequest) + return + } + + spec, err := loadFormSpec(req.SpecPath) + if err != nil { + http.Error(w, "form spec error: "+err.Error(), http.StatusInternalServerError) + return + } + + if errs := jsonschema.Validate(spec.Schema, data); len(errs) > 0 { + writeValidationErrors(w, errs) + return + } + + yamlBytes, err := yaml.Marshal(data) + if err != nil { + http.Error(w, "marshal yaml: "+err.Error(), http.StatusInternalServerError) + return + } + if err := zddc.WriteAtomic(req.DataPath, yamlBytes); err != nil { + http.Error(w, "write: "+err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ok":true}`)) +} + +// --- Helpers ----------------------------------------------------------------- + +func loadFormSpec(path string) (*FormSpec, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var spec FormSpec + if err := yaml.Unmarshal(data, &spec); err != nil { + return nil, fmt.Errorf("parse: %w", err) + } + if spec.Schema == nil { + return nil, errors.New("form spec has no schema") + } + return &spec, nil +} + +// decodeRequestData reads the request body as JSON (preferred) or YAML, +// returning the decoded value as the same `any` shape jsonschema.Validate +// expects. Body size is capped at 1 MiB. +func decodeRequestData(r *http.Request) (interface{}, error) { + body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) + if err != nil { + return nil, err + } + if len(body) == 0 { + return nil, errors.New("empty body") + } + ct := strings.ToLower(strings.TrimSpace(strings.Split(r.Header.Get("Content-Type"), ";")[0])) + if ct == "application/yaml" || ct == "application/x-yaml" || ct == "text/yaml" { + var v interface{} + if err := yaml.Unmarshal(body, &v); err != nil { + return nil, err + } + return normalizeYAMLForJSON(v), nil + } + var v interface{} + dec := json.NewDecoder(strings.NewReader(string(body))) + dec.UseNumber() + if err := dec.Decode(&v); err != nil { + return nil, err + } + return normalizeJSONNumbers(v), nil +} + +// normalizeYAMLForJSON converts yaml.v3's `map[interface{}]interface{}` (which +// it produces for mappings under a generic `interface{}` target) into +// `map[string]interface{}` so the rest of the pipeline can assume JSON shape. +// Also recurses through slices. +func normalizeYAMLForJSON(v interface{}) interface{} { + switch x := v.(type) { + case map[interface{}]interface{}: + out := make(map[string]interface{}, len(x)) + for k, val := range x { + out[fmt.Sprintf("%v", k)] = normalizeYAMLForJSON(val) + } + return out + case map[string]interface{}: + out := make(map[string]interface{}, len(x)) + for k, val := range x { + out[k] = normalizeYAMLForJSON(val) + } + return out + case []interface{}: + out := make([]interface{}, len(x)) + for i, item := range x { + out[i] = normalizeYAMLForJSON(item) + } + return out + } + return v +} + +// normalizeJSONNumbers converts json.Number values into int64 (when integral) +// or float64. Without this, the validator would have to know about +// json.Number, which would couple the focused jsonschema package to the +// json decoder we happen to use here. +func normalizeJSONNumbers(v interface{}) interface{} { + switch x := v.(type) { + case json.Number: + if i, err := x.Int64(); err == nil { + return i + } + if f, err := x.Float64(); err == nil { + return f + } + return x.String() + case map[string]interface{}: + for k, val := range x { + x[k] = normalizeJSONNumbers(val) + } + return x + case []interface{}: + for i, item := range x { + x[i] = normalizeJSONNumbers(item) + } + return x + } + return v +} + +func writeValidationErrors(w http.ResponseWriter, errs []jsonschema.Error) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnprocessableEntity) + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "errors": errs, + }) +} + +// sanitizeEmail produces a safe filename component from an email address. +// "casey@proton.me" → "casey-at-proton-me". Conservative: also flattens any +// path-meaningful characters so a malicious email can't escape its directory, +// then collapses runs of '-' and trims leading/trailing '-' so the resulting +// filename is well-formed. +func sanitizeEmail(s string) string { + s = strings.ReplaceAll(s, "@", "-at-") + s = strings.ReplaceAll(s, ".", "-") + var b strings.Builder + for _, r := range s { + switch { + case r >= 'a' && r <= 'z', + r >= 'A' && r <= 'Z', + r >= '0' && r <= '9', + r == '-', r == '_': + b.WriteRune(r) + } + } + out := b.String() + for strings.Contains(out, "--") { + out = strings.ReplaceAll(out, "--", "-") + } + out = strings.Trim(out, "-") + if out == "" { + out = "anonymous" + } + return out +} + +// pickAvailableFilename tries ``, then `-2`, ..., +// `-100`, returning the first that does not yet exist on disk. +func pickAvailableFilename(dir, base, ext string) (path, name string, ok bool) { + name = base + ext + path = filepath.Join(dir, name) + if !fileExists(path) { + return path, name, true + } + for i := 2; i < 100; i++ { + name = base + "-" + strconv.Itoa(i) + ext + path = filepath.Join(dir, name) + if !fileExists(path) { + return path, name, true + } + } + return "", "", false +} + +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +// injectFormContext rewrites the embedded form HTML's #form-context placeholder +// with a serialized form context. Defends against script-tag breakouts in the +// JSON values by escaping any ". +func injectFormContext(template []byte, ctx formContext) ([]byte, error) { + js, err := json.Marshal(ctx) + if err != nil { + return nil, err + } + js = []byte(strings.ReplaceAll(string(js), "{}`) + if !bytesContains(template, needle) { + return nil, errors.New("#form-context placeholder not found in template") + } + replacement := append([]byte(``)...) + + out := bytesReplace(template, needle, replacement) + return out, nil +} + +// Tiny bytes helpers — scoped here so we don't pull in "bytes" for two calls. +func bytesContains(haystack, needle []byte) bool { + return strings.Contains(string(haystack), string(needle)) +} + +func bytesReplace(haystack, needle, replacement []byte) []byte { + return []byte(strings.Replace(string(haystack), string(needle), string(replacement), 1)) +} diff --git a/zddc/internal/handler/formhandler_test.go b/zddc/internal/handler/formhandler_test.go new file mode 100644 index 0000000..243428b --- /dev/null +++ b/zddc/internal/handler/formhandler_test.go @@ -0,0 +1,491 @@ +package handler + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "codeberg.org/VARASYS/ZDDC/zddc/internal/config" + "codeberg.org/VARASYS/ZDDC/zddc/internal/zddc" +) + +const sampleFormSpec = `title: Daily Safety Check-In +schema: + type: object + required: [date, location] + additionalProperties: false + properties: + date: + type: string + format: date + location: + type: string + enum: [Site A, Site B] + severity: + type: integer + minimum: 1 + maximum: 5 + notes: + type: string +ui: + notes: + ui:widget: textarea +` + +// formTestSetup writes a directory tree under a temp root including a +// safety.form.yaml at /Working/safety.form.yaml plus optional .zddc files. +// Returns (config, do) where do dispatches a request through ServeForm via +// the same recognize → serve path the production catch-all uses. +func formTestSetup(t *testing.T, zddcFiles map[string]string) (config.Config, func(method, target, email, body string) *httptest.ResponseRecorder) { + t.Helper() + root := t.TempDir() + + // Always seed the form spec at /Working/safety.form.yaml. + working := filepath.Join(root, "Working") + if err := os.MkdirAll(working, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + specPath := filepath.Join(working, "safety.form.yaml") + if err := os.WriteFile(specPath, []byte(sampleFormSpec), 0o644); err != nil { + t.Fatalf("write spec: %v", err) + } + + for rel, body := range zddcFiles { + dir := filepath.Join(root, rel) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("mkdir %s: %v", dir, err) + } + zddc.InvalidateCache(dir) + if body == "" { + continue + } + if err := os.WriteFile(filepath.Join(dir, ".zddc"), []byte(body), 0o644); err != nil { + t.Fatalf("write .zddc: %v", err) + } + } + cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"} + + do := func(method, target, email, body string) *httptest.ResponseRecorder { + var req *http.Request + if body != "" { + req = httptest.NewRequest(method, target, bytes.NewReader([]byte(body))) + req.Header.Set("Content-Type", "application/json") + } else { + req = httptest.NewRequest(method, target, nil) + } + ctx := context.WithValue(req.Context(), EmailKey, email) + req = req.WithContext(ctx) + rec := httptest.NewRecorder() + + formReq := RecognizeFormRequest(cfg.Root, method, target) + if formReq == nil { + rec.WriteHeader(http.StatusNotFound) + return rec + } + ServeForm(cfg, formReq, rec, req) + return rec + } + return cfg, do +} + +func TestRecognizeFormRequest(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "Working", "safety"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "Working", "safety.form.yaml"), []byte("schema:\n type: object\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "Working", "safety", "2026-05-01-casey.yaml"), []byte("date: 2026-05-01\n"), 0o644); err != nil { + t.Fatal(err) + } + + cases := []struct { + method, url string + wantKind string // "" means expect nil + wantSpec string + wantData string + }{ + {"GET", "/Working/safety.form.html", "render-empty", "Working/safety.form.yaml", ""}, + {"POST", "/Working/safety.form.html", "create", "Working/safety.form.yaml", ""}, + {"GET", "/Working/safety/2026-05-01-casey.yaml.html", "render-edit", "Working/safety.form.yaml", "Working/safety/2026-05-01-casey.yaml"}, + {"POST", "/Working/safety/2026-05-01-casey.yaml.html", "update", "Working/safety.form.yaml", "Working/safety/2026-05-01-casey.yaml"}, + // No spec → not a form request. + {"GET", "/Working/missing.form.html", "", "", ""}, + // Bare .yaml (not .yaml.html) → not a form request, falls through to static. + {"GET", "/Working/safety/2026-05-01-casey.yaml", "", "", ""}, + // Random .html → falls through. + {"GET", "/index.html", "", "", ""}, + // Wrong method. + {"DELETE", "/Working/safety.form.html", "", "", ""}, + // Path traversal attempt. + {"GET", "/../etc/passwd.form.html", "", "", ""}, + } + + for _, tc := range cases { + t.Run(tc.method+" "+tc.url, func(t *testing.T) { + got := RecognizeFormRequest(root, tc.method, tc.url) + if tc.wantKind == "" { + if got != nil { + t.Errorf("got %+v, want nil", got) + } + return + } + if got == nil { + t.Fatalf("got nil, want kind=%q", tc.wantKind) + } + if got.Kind != tc.wantKind { + t.Errorf("Kind = %q want %q", got.Kind, tc.wantKind) + } + wantSpec := filepath.Join(root, tc.wantSpec) + if got.SpecPath != wantSpec { + t.Errorf("SpecPath = %q want %q", got.SpecPath, wantSpec) + } + if tc.wantData != "" { + wantData := filepath.Join(root, tc.wantData) + if got.DataPath != wantData { + t.Errorf("DataPath = %q want %q", got.DataPath, wantData) + } + } else if got.DataPath != "" { + t.Errorf("DataPath = %q want empty", got.DataPath) + } + }) + } +} + +func TestRenderEmptyForm(t *testing.T) { + _, do := formTestSetup(t, map[string]string{ + "": `acl: + allow: ["*@example.com"] +`, + }) + rec := do(http.MethodGet, "/Working/safety.form.html", "casey@example.com", "") + if rec.Code != http.StatusOK { + t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String()) + } + body := rec.Body.String() + // The placeholder should be replaced with real context content. + if !strings.Contains(body, ``) { + t.Fatal("placeholder {} was not replaced") + } + // Title from the form spec should land in the rendered context. + if !strings.Contains(body, "Daily Safety Check-In") { + t.Errorf("expected title in body, got first 500 chars:\n%s", body[:min(500, len(body))]) + } +} + +func TestRenderEmptyForm_ACLDeny(t *testing.T) { + _, do := formTestSetup(t, map[string]string{ + "": `acl: + allow: ["root@example.com"] +`, + }) + rec := do(http.MethodGet, "/Working/safety.form.html", "stranger@example.com", "") + if rec.Code != http.StatusForbidden { + t.Errorf("status = %d want 403; body = %s", rec.Code, rec.Body.String()) + } +} + +func TestCreateSubmission_Valid(t *testing.T) { + cfg, do := formTestSetup(t, map[string]string{ + "": `acl: + allow: ["*@example.com"] +`, + }) + + body := `{"date":"2026-05-01","location":"Site A","severity":3,"notes":"all clear"}` + rec := do(http.MethodPost, "/Working/safety.form.html", "casey@example.com", body) + if rec.Code != http.StatusCreated { + t.Fatalf("status = %d want 201; body = %s", rec.Code, rec.Body.String()) + } + loc := rec.Header().Get("Location") + if loc == "" { + t.Fatal("Location header missing") + } + // Filename uses the server's UTC date (not the user-entered date), so just + // check the path prefix and email-sanitized component. + if !strings.HasPrefix(loc, "/Working/safety/") || !strings.Contains(loc, "casey-at-example-com") { + t.Errorf("Location = %q; expected /Working/safety/...casey-at-example-com...", loc) + } + + // File should exist on disk with the submitted values reflected. + abs := filepath.Join(cfg.Root, filepath.FromSlash(strings.TrimPrefix(loc, "/"))) + yamlBytes, err := os.ReadFile(abs) + if err != nil { + t.Fatalf("read submission: %v", err) + } + yamlStr := string(yamlBytes) + if !strings.Contains(yamlStr, "2026-05-01") { + t.Errorf("submission YAML missing user-entered date: %s", yamlStr) + } + if !strings.Contains(yamlStr, "Site A") { + t.Errorf("submission YAML missing location: %s", yamlStr) + } + if !strings.Contains(yamlStr, "all clear") { + t.Errorf("submission YAML missing notes: %s", yamlStr) + } +} + +func TestCreateSubmission_Invalid_Returns422(t *testing.T) { + _, do := formTestSetup(t, map[string]string{ + "": `acl: + allow: ["*@example.com"] +`, + }) + + // Missing required `location`, severity out of range. + body := `{"date":"2026-05-01","severity":99}` + rec := do(http.MethodPost, "/Working/safety.form.html", "casey@example.com", body) + if rec.Code != http.StatusUnprocessableEntity { + t.Fatalf("status = %d want 422; body = %s", rec.Code, rec.Body.String()) + } + + var resp struct { + Errors []struct { + Path string `json:"path"` + Message string `json:"message"` + } `json:"errors"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode response: %v; body = %s", err, rec.Body.String()) + } + if len(resp.Errors) < 2 { + t.Errorf("expected at least 2 errors, got %d: %+v", len(resp.Errors), resp.Errors) + } + gotPaths := map[string]bool{} + for _, e := range resp.Errors { + gotPaths[e.Path] = true + } + if !gotPaths["/location"] { + t.Errorf("expected error at /location, got paths %v", gotPaths) + } + if !gotPaths["/severity"] { + t.Errorf("expected error at /severity, got paths %v", gotPaths) + } +} + +func TestCreateSubmission_ACLDeny(t *testing.T) { + _, do := formTestSetup(t, map[string]string{ + "": `acl: + allow: ["root@example.com"] +`, + }) + body := `{"date":"2026-05-01","location":"Site A"}` + rec := do(http.MethodPost, "/Working/safety.form.html", "stranger@example.com", body) + if rec.Code != http.StatusForbidden { + t.Errorf("status = %d want 403; body = %s", rec.Code, rec.Body.String()) + } +} + +func TestCreateSubmission_NoAuth_Returns401(t *testing.T) { + _, do := formTestSetup(t, map[string]string{ + "": `acl: + allow: ["*"] +`, + }) + body := `{"date":"2026-05-01","location":"Site A"}` + rec := do(http.MethodPost, "/Working/safety.form.html", "", body) + if rec.Code != http.StatusUnauthorized { + t.Errorf("status = %d want 401; body = %s", rec.Code, rec.Body.String()) + } +} + +func TestCreateSubmission_FilenameCollision(t *testing.T) { + cfg, do := formTestSetup(t, map[string]string{ + "": `acl: + allow: ["*@example.com"] +`, + }) + body := `{"date":"2026-05-01","location":"Site A"}` + + first := do(http.MethodPost, "/Working/safety.form.html", "casey@example.com", body) + if first.Code != http.StatusCreated { + t.Fatalf("first submit: status = %d; body = %s", first.Code, first.Body.String()) + } + second := do(http.MethodPost, "/Working/safety.form.html", "casey@example.com", body) + if second.Code != http.StatusCreated { + t.Fatalf("second submit: status = %d; body = %s", second.Code, second.Body.String()) + } + loc1 := first.Header().Get("Location") + loc2 := second.Header().Get("Location") + if loc1 == loc2 { + t.Errorf("collision suffix not applied: both submissions at %q", loc1) + } + if !strings.Contains(loc2, "-2.yaml") { + t.Errorf("second submission Location = %q; expected -2.yaml suffix", loc2) + } + + // Both files exist on disk. + for _, l := range []string{loc1, loc2} { + abs := filepath.Join(cfg.Root, filepath.FromSlash(strings.TrimPrefix(l, "/"))) + if _, err := os.Stat(abs); err != nil { + t.Errorf("expected submission at %s: %v", abs, err) + } + } +} + +func TestRenderEdit_LoadsSubmission(t *testing.T) { + cfg, do := formTestSetup(t, map[string]string{ + "": `acl: + allow: ["*@example.com"] +`, + }) + + // Pre-populate a submission file. + subDir := filepath.Join(cfg.Root, "Working", "safety") + if err := os.MkdirAll(subDir, 0o755); err != nil { + t.Fatal(err) + } + subPath := filepath.Join(subDir, "2026-05-01-jamie-at-example-com.yaml") + if err := os.WriteFile(subPath, []byte("date: 2026-05-01\nlocation: Site B\nseverity: 4\n"), 0o644); err != nil { + t.Fatal(err) + } + + rec := do(http.MethodGet, "/Working/safety/2026-05-01-jamie-at-example-com.yaml.html", "jamie@example.com", "") + if rec.Code != http.StatusOK { + t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String()) + } + body := rec.Body.String() + // The form-context JSON should now contain the loaded data. + if !strings.Contains(body, `"location":"Site B"`) { + t.Errorf("expected loaded location in form context; first 500 chars:\n%s", body[:min(500, len(body))]) + } +} + +func TestUpdateSubmission_OverwritesFile(t *testing.T) { + cfg, do := formTestSetup(t, map[string]string{ + "": `acl: + allow: ["*@example.com"] +`, + }) + + subDir := filepath.Join(cfg.Root, "Working", "safety") + if err := os.MkdirAll(subDir, 0o755); err != nil { + t.Fatal(err) + } + subPath := filepath.Join(subDir, "2026-05-01-jamie-at-example-com.yaml") + if err := os.WriteFile(subPath, []byte("date: 2026-05-01\nlocation: Site A\n"), 0o644); err != nil { + t.Fatal(err) + } + + body := `{"date":"2026-05-01","location":"Site B","severity":2}` + rec := do(http.MethodPost, "/Working/safety/2026-05-01-jamie-at-example-com.yaml.html", "jamie@example.com", body) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String()) + } + + updated, err := os.ReadFile(subPath) + if err != nil { + t.Fatalf("read updated: %v", err) + } + if !strings.Contains(string(updated), "Site B") { + t.Errorf("update did not change location; got: %s", string(updated)) + } + if !strings.Contains(string(updated), "severity: 2") { + t.Errorf("update did not include severity; got: %s", string(updated)) + } +} + +func TestUpdateSubmission_NotFound(t *testing.T) { + _, do := formTestSetup(t, map[string]string{ + "": `acl: + allow: ["*@example.com"] +`, + }) + body := `{"date":"2026-05-01","location":"Site A"}` + rec := do(http.MethodPost, "/Working/safety/missing.yaml.html", "jamie@example.com", body) + if rec.Code != http.StatusNotFound { + t.Errorf("status = %d want 404; body = %s", rec.Code, rec.Body.String()) + } +} + +func TestSanitizeEmail(t *testing.T) { + cases := map[string]string{ + "casey@proton.me": "casey-at-proton-me", + "first.last@example.com": "first-last-at-example-com", + "casey+tag@example.io": "caseytag-at-example-io", + "": "anonymous", + "../etc/passwd@evil.com": "etcpasswd-at-evil-com", + } + for in, want := range cases { + got := sanitizeEmail(in) + if got != want { + t.Errorf("sanitizeEmail(%q) = %q want %q", in, got, want) + } + } +} + +func TestPickAvailableFilename_Collision(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "a.yaml"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + path, name, ok := pickAvailableFilename(dir, "a", ".yaml") + if !ok { + t.Fatal("ok=false on first collision step") + } + if name != "a-2.yaml" { + t.Errorf("name = %q want a-2.yaml", name) + } + if filepath.Base(path) != "a-2.yaml" { + t.Errorf("path basename = %q want a-2.yaml", filepath.Base(path)) + } +} + +func TestInjectFormContext_PlaceholderReplaced(t *testing.T) { + template := []byte(``) + out, err := injectFormContext(template, formContext{ + Title: "X", + SubmitURL: "/x", + }) + if err != nil { + t.Fatalf("inject: %v", err) + } + s := string(out) + if strings.Contains(s, `"application/json">{}`) { + t.Error("placeholder still present") + } + if !strings.Contains(s, `"title":"X"`) { + t.Errorf("missing title in injected JSON; got: %s", s) + } +} + +func TestInjectFormContext_EscapesScriptCloseInValue(t *testing.T) { + // A schema description containing "" must not break out of the + // inline JSON. encoding/json's default escapes `<` → `<`, so the + // rendered output should still contain exactly one (the actual + // closing tag) regardless of what the user-controlled value held. + template := []byte(``) + ctx := formContext{ + Title: `legit `, + SubmitURL: "/x", + } + out, err := injectFormContext(template, ctx) + if err != nil { + t.Fatalf("inject: %v", err) + } + s := string(out) + if n := strings.Count(s, ""); n != 1 { + t.Errorf("expected exactly 1 closing tag, got %d:\n%s", n, s) + } + // The user-controlled value should be present in escaped form. + if !strings.Contains(s, ``) { + t.Errorf("expected escaped \\u003c/script\\u003e in output:\n%s", s) + } +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/zddc/internal/jsonschema/format.go b/zddc/internal/jsonschema/format.go new file mode 100644 index 0000000..13ce317 --- /dev/null +++ b/zddc/internal/jsonschema/format.go @@ -0,0 +1,29 @@ +package jsonschema + +import ( + "regexp" + "time" +) + +var ( + dateRe = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`) + emailRe = regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`) +) + +// formatValid checks whether s satisfies the named format. Returns true for +// formats we don't support — JSON Schema treats `format` as an annotation by +// default, so unknown formats are non-failing. +func formatValid(format, s string) bool { + switch format { + case "date": + if !dateRe.MatchString(s) { + return false + } + // Reject obviously-malformed dates like 2026-13-40. + _, err := time.Parse("2006-01-02", s) + return err == nil + case "email": + return emailRe.MatchString(s) + } + return true +} diff --git a/zddc/internal/jsonschema/jsonschema_test.go b/zddc/internal/jsonschema/jsonschema_test.go new file mode 100644 index 0000000..c098d86 --- /dev/null +++ b/zddc/internal/jsonschema/jsonschema_test.go @@ -0,0 +1,273 @@ +package jsonschema + +import ( + "encoding/json" + "testing" +) + +// fl returns a *float64 pointing at v — convenience for table tests. +func fl(v float64) *float64 { return &v } +func ip(v int) *int { return &v } + +// jsonAny decodes a JSON literal into the same `any` shape that the form +// handler hands to Validate (json.Unmarshal of the request body). +func jsonAny(t *testing.T, s string) any { + t.Helper() + var v any + if err := json.Unmarshal([]byte(s), &v); err != nil { + t.Fatalf("decode %q: %v", s, err) + } + return v +} + +func TestValidate_Types(t *testing.T) { + cases := []struct { + name string + schema *Schema + valueJS string + wantErr bool + }{ + {"string-ok", &Schema{Type: "string"}, `"hi"`, false}, + {"string-wrong-type", &Schema{Type: "string"}, `42`, true}, + {"number-ok-int", &Schema{Type: "number"}, `42`, false}, + {"number-ok-float", &Schema{Type: "number"}, `3.14`, false}, + {"number-wrong-type", &Schema{Type: "number"}, `"hi"`, true}, + {"integer-ok", &Schema{Type: "integer"}, `42`, false}, + {"integer-rejects-float", &Schema{Type: "integer"}, `3.14`, true}, + {"integer-accepts-floaty-int", &Schema{Type: "integer"}, `42.0`, false}, + {"boolean-ok", &Schema{Type: "boolean"}, `true`, false}, + {"boolean-wrong-type", &Schema{Type: "boolean"}, `"true"`, true}, + {"null-ok", &Schema{Type: "null"}, `null`, false}, + {"array-ok", &Schema{Type: "array"}, `[]`, false}, + {"array-wrong-type", &Schema{Type: "array"}, `{}`, true}, + {"object-ok", &Schema{Type: "object"}, `{}`, false}, + {"object-wrong-type", &Schema{Type: "object"}, `[]`, true}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + errs := Validate(tc.schema, jsonAny(t, tc.valueJS)) + if (len(errs) > 0) != tc.wantErr { + t.Fatalf("Validate errs = %v; wantErr=%v", errs, tc.wantErr) + } + }) + } +} + +func TestValidate_Enum(t *testing.T) { + s := &Schema{Type: "string", Enum: []any{"a", "b", "c"}} + + if errs := Validate(s, "a"); len(errs) != 0 { + t.Errorf("a: unexpected errs %v", errs) + } + if errs := Validate(s, "z"); len(errs) == 0 { + t.Errorf("z: expected enum error") + } + + // Numeric enum with int↔float coercion: schema authored with int, value + // arrives as float64 (JSON default). + num := &Schema{Type: "integer", Enum: []any{1, 2, 3}} + if errs := Validate(num, jsonAny(t, `2`)); len(errs) != 0 { + t.Errorf("int 2 against int enum: unexpected errs %v", errs) + } + if errs := Validate(num, jsonAny(t, `4`)); len(errs) == 0 { + t.Errorf("int 4 against int enum: expected error") + } +} + +func TestValidate_StringConstraints(t *testing.T) { + s := &Schema{Type: "string", MinLength: ip(3), MaxLength: ip(8)} + cases := map[string]int{ + `"hi"`: 1, // too short + `"hello"`: 0, + `"hellothere"`: 1, // too long + } + for v, want := range cases { + errs := Validate(s, jsonAny(t, v)) + if len(errs) != want { + t.Errorf("%s: got %d errs (%v), want %d", v, len(errs), errs, want) + } + } +} + +func TestValidate_NumberConstraints(t *testing.T) { + s := &Schema{Type: "integer", Minimum: fl(1), Maximum: fl(5)} + cases := []struct { + v string + want int + }{ + {`0`, 1}, {`1`, 0}, {`3`, 0}, {`5`, 0}, {`6`, 1}, + } + for _, tc := range cases { + errs := Validate(s, jsonAny(t, tc.v)) + if len(errs) != tc.want { + t.Errorf("v=%s: got %d errs (%v), want %d", tc.v, len(errs), errs, tc.want) + } + } +} + +func TestValidate_Format_Date(t *testing.T) { + s := &Schema{Type: "string", Format: "date"} + cases := map[string]bool{ + `"2026-05-01"`: false, + `"2026-13-01"`: true, // bad month + `"2026-02-30"`: true, // bad day + `"05/01/2026"`: true, // wrong format + `"2026-5-1"`: true, // missing zero-pad + } + for v, wantErr := range cases { + errs := Validate(s, jsonAny(t, v)) + if (len(errs) > 0) != wantErr { + t.Errorf("%s: got errs=%v wantErr=%v", v, errs, wantErr) + } + } +} + +func TestValidate_Format_Email(t *testing.T) { + s := &Schema{Type: "string", Format: "email"} + cases := map[string]bool{ + `"casey@example.com"`: false, + `"casey+tag@ex.io"`: false, + `"not-an-email"`: true, + `"missing@dot"`: true, + `"@nouser.com"`: true, + } + for v, wantErr := range cases { + errs := Validate(s, jsonAny(t, v)) + if (len(errs) > 0) != wantErr { + t.Errorf("%s: got errs=%v wantErr=%v", v, errs, wantErr) + } + } +} + +func TestValidate_Object_Required(t *testing.T) { + s := &Schema{ + Type: "object", + Required: []string{"name", "age"}, + Properties: map[string]*Schema{ + "name": {Type: "string"}, + "age": {Type: "integer"}, + }, + } + + errs := Validate(s, jsonAny(t, `{"name":"Casey","age":42}`)) + if len(errs) != 0 { + t.Errorf("complete object: unexpected errs %v", errs) + } + + errs = Validate(s, jsonAny(t, `{"name":"Casey"}`)) + if len(errs) != 1 || errs[0].Path != "/age" { + t.Errorf("missing age: got errs=%v want one error at /age", errs) + } + + errs = Validate(s, jsonAny(t, `{}`)) + if len(errs) != 2 { + t.Errorf("empty object: got %d errs (%v) want 2", len(errs), errs) + } +} + +func TestValidate_Object_AdditionalPropertiesFalse(t *testing.T) { + s := &Schema{ + Type: "object", + Properties: map[string]*Schema{"a": {Type: "string"}}, + AdditionalProperties: false, + } + + if errs := Validate(s, jsonAny(t, `{"a":"hi"}`)); len(errs) != 0 { + t.Errorf("declared-only: unexpected %v", errs) + } + + errs := Validate(s, jsonAny(t, `{"a":"hi","b":1}`)) + if len(errs) != 1 || errs[0].Path != "/b" { + t.Errorf("extra prop: got %v want one error at /b", errs) + } +} + +func TestValidate_Object_NestedErrorPaths(t *testing.T) { + s := &Schema{ + Type: "object", + Properties: map[string]*Schema{ + "inner": { + Type: "object", + Properties: map[string]*Schema{ + "n": {Type: "integer", Minimum: fl(0)}, + }, + }, + }, + } + errs := Validate(s, jsonAny(t, `{"inner":{"n":-5}}`)) + if len(errs) != 1 || errs[0].Path != "/inner/n" { + t.Errorf("nested path: got %v want /inner/n", errs) + } +} + +func TestValidate_Array_Items(t *testing.T) { + s := &Schema{ + Type: "array", + Items: &Schema{Type: "integer", Minimum: fl(0)}, + } + + if errs := Validate(s, jsonAny(t, `[1,2,3]`)); len(errs) != 0 { + t.Errorf("valid array: unexpected %v", errs) + } + + errs := Validate(s, jsonAny(t, `[1,-2,3,-4]`)) + if len(errs) != 2 { + t.Errorf("two bad items: got %d errs %v want 2", len(errs), errs) + } + if errs[0].Path != "/1" || errs[1].Path != "/3" { + t.Errorf("array paths: got %v want /1 then /3", errs) + } +} + +func TestValidate_Array_NestedObjects(t *testing.T) { + s := &Schema{ + Type: "array", + Items: &Schema{ + Type: "object", + Required: []string{"k"}, + Properties: map[string]*Schema{ + "k": {Type: "string"}, + "v": {Type: "integer", Minimum: fl(1), Maximum: fl(5)}, + }, + }, + } + errs := Validate(s, jsonAny(t, `[{"k":"a","v":3},{"v":99}]`)) + // Expected: missing k at /1/k, v out of range at /1/v + if len(errs) != 2 { + t.Fatalf("got %d errs (%v) want 2", len(errs), errs) + } + gotPaths := map[string]bool{errs[0].Path: true, errs[1].Path: true} + if !gotPaths["/1/k"] || !gotPaths["/1/v"] { + t.Errorf("array-of-objects paths: got %v", errs) + } +} + +func TestValidate_PtrEnc_SpecialChars(t *testing.T) { + s := &Schema{ + Type: "object", + Required: []string{"a/b", "c~d"}, + Properties: map[string]*Schema{ + "a/b": {Type: "string"}, + "c~d": {Type: "string"}, + }, + } + errs := Validate(s, jsonAny(t, `{}`)) + if len(errs) != 2 { + t.Fatalf("got %d errs %v", len(errs), errs) + } + gotPaths := map[string]bool{errs[0].Path: true, errs[1].Path: true} + // Per RFC 6901: '/' → '~1', '~' → '~0'. + if !gotPaths["/a~1b"] { + t.Errorf("expected /a~1b in %v", errs) + } + if !gotPaths["/c~0d"] { + t.Errorf("expected /c~0d in %v", errs) + } +} + +func TestValidate_NilSchemaIsNoOp(t *testing.T) { + if errs := Validate(nil, "anything"); errs != nil { + t.Errorf("nil schema returned errs: %v", errs) + } +} diff --git a/zddc/internal/jsonschema/schema.go b/zddc/internal/jsonschema/schema.go new file mode 100644 index 0000000..689a5cc --- /dev/null +++ b/zddc/internal/jsonschema/schema.go @@ -0,0 +1,49 @@ +// Package jsonschema is a focused JSON Schema 2020-12 validator covering only +// the subset of keywords used by the form-data system. See validate.go for the +// supported keyword list. Unsupported keywords are silently ignored — the +// form-spec meta-schema enforces that authors only use the supported subset. +// +// This package is intentionally smaller than a full JSON Schema implementation +// (e.g. github.com/santhosh-tekuri/jsonschema). Match-implementation-cost-to- +// surface-used: the form system never needs $ref, oneOf/anyOf, if/then/else, +// or remote schema fetch in v0; reimplementing that machinery would be more +// code than the entire validator we ship here. +// +// When the v1+ form-spec adds those features, revisit this trade-off. +package jsonschema + +// Schema is the in-memory representation of a JSON Schema. Fields use both +// YAML and JSON tags so the same struct round-trips through either encoding — +// form specs are authored in YAML, but data submissions arrive as JSON. +// +// Pointer-typed fields (Minimum, Maximum, MinLength, MaxLength) distinguish +// "unset" from "set to zero", which matters because zero is a valid bound. +// +// AdditionalProperties is `any` to accept either a bool (the v0-supported +// shape — only `false` is enforced; `true` and unset are both "allow") or a +// nested schema (parsed but not enforced in v0). +type Schema struct { + Type string `yaml:"type" json:"type,omitempty"` + Properties map[string]*Schema `yaml:"properties" json:"properties,omitempty"` + Required []string `yaml:"required" json:"required,omitempty"` + Items *Schema `yaml:"items" json:"items,omitempty"` + Enum []any `yaml:"enum" json:"enum,omitempty"` + Minimum *float64 `yaml:"minimum" json:"minimum,omitempty"` + Maximum *float64 `yaml:"maximum" json:"maximum,omitempty"` + MinLength *int `yaml:"minLength" json:"minLength,omitempty"` + MaxLength *int `yaml:"maxLength" json:"maxLength,omitempty"` + Format string `yaml:"format" json:"format,omitempty"` + AdditionalProperties any `yaml:"additionalProperties" json:"additionalProperties,omitempty"` + Title string `yaml:"title" json:"title,omitempty"` + Description string `yaml:"description" json:"description,omitempty"` + Default any `yaml:"default" json:"default,omitempty"` +} + +// Error reports a single validation failure. Path is a JSON Pointer (RFC 6901) +// pointing at the offending value within the validated document. Both fields +// flow through the form handler unchanged into the JSON response, so the +// browser-side renderer can locate and highlight the right widget. +type Error struct { + Path string `json:"path"` + Message string `json:"message"` +} diff --git a/zddc/internal/jsonschema/validate.go b/zddc/internal/jsonschema/validate.go new file mode 100644 index 0000000..45b93de --- /dev/null +++ b/zddc/internal/jsonschema/validate.go @@ -0,0 +1,277 @@ +package jsonschema + +import ( + "reflect" + "strconv" + "strings" + "unicode/utf8" +) + +// Validate reports all violations of s against v. v is the decoded data — +// typically the result of json.Unmarshal or yaml.Unmarshal into `any`. +// +// Returns nil when v conforms. Returns one or more Error entries otherwise. +// Errors carry JSON-Pointer paths so the form renderer can attach each +// message to the right widget. +// +// Supported keywords (v0 subset): type, properties, required, items, enum, +// minimum, maximum, minLength, maxLength, format (date, email), +// additionalProperties: false. Everything else is silently ignored — the +// form-spec meta-schema enforces that authors stay within this subset. +func Validate(s *Schema, v any) []Error { + if s == nil { + return nil + } + var errs []Error + validate(s, v, "", &errs) + return errs +} + +func validate(s *Schema, v any, path string, errs *[]Error) { + if s == nil { + return + } + + // Type check first — a wrong-typed value can't satisfy the rest, and + // dispatching the constraint checks below assumes the type matches. + if s.Type != "" && !typeMatches(s.Type, v) { + *errs = append(*errs, Error{ + Path: path, + Message: typeMessage(s.Type, v), + }) + return + } + + // enum: value must be one of the listed alternatives. Numeric comparisons + // coerce int↔float to handle JSON's float64 vs YAML's int mismatch. + if len(s.Enum) > 0 && !enumContains(s.Enum, v) { + *errs = append(*errs, Error{ + Path: path, + Message: "must be one of the allowed values", + }) + return + } + + switch s.Type { + case "string": + validateString(s, v.(string), path, errs) + case "number", "integer": + validateNumber(s, v, path, errs) + case "object": + validateObject(s, v, path, errs) + case "array": + validateArray(s, v, path, errs) + } +} + +func validateString(s *Schema, str, path string, errs *[]Error) { + n := utf8.RuneCountInString(str) + if s.MinLength != nil && n < *s.MinLength { + *errs = append(*errs, Error{Path: path, Message: minLenMsg(*s.MinLength)}) + } + if s.MaxLength != nil && n > *s.MaxLength { + *errs = append(*errs, Error{Path: path, Message: maxLenMsg(*s.MaxLength)}) + } + if s.Format != "" && !formatValid(s.Format, str) { + *errs = append(*errs, Error{Path: path, Message: "must be a valid " + s.Format}) + } +} + +func validateNumber(s *Schema, v any, path string, errs *[]Error) { + f, _ := toFloat(v) + if s.Minimum != nil && f < *s.Minimum { + *errs = append(*errs, Error{Path: path, Message: "must be ≥ " + numStr(*s.Minimum)}) + } + if s.Maximum != nil && f > *s.Maximum { + *errs = append(*errs, Error{Path: path, Message: "must be ≤ " + numStr(*s.Maximum)}) + } +} + +func validateObject(s *Schema, v any, path string, errs *[]Error) { + obj, ok := v.(map[string]any) + if !ok { + return + } + + for _, name := range s.Required { + if _, present := obj[name]; !present { + *errs = append(*errs, Error{ + Path: ptrPush(path, name), + Message: "required", + }) + } + } + + for name, propSchema := range s.Properties { + if val, present := obj[name]; present { + validate(propSchema, val, ptrPush(path, name), errs) + } + } + + // additionalProperties: false → reject anything not declared in properties. + // Only the bool-false form is enforced in v0; schema form is parsed but ignored. + if ap, ok := s.AdditionalProperties.(bool); ok && !ap { + for name := range obj { + if _, declared := s.Properties[name]; !declared { + *errs = append(*errs, Error{ + Path: ptrPush(path, name), + Message: "unexpected property", + }) + } + } + } +} + +func validateArray(s *Schema, v any, path string, errs *[]Error) { + arr, ok := v.([]any) + if !ok { + return + } + if s.Items != nil { + for i, item := range arr { + validate(s.Items, item, ptrPushIdx(path, i), errs) + } + } +} + +// --- Type / coercion helpers ------------------------------------------------- + +func typeMatches(t string, v any) bool { + switch t { + case "null": + return v == nil + case "boolean": + _, ok := v.(bool) + return ok + case "string": + _, ok := v.(string) + return ok + case "number": + _, ok := toFloat(v) + return ok + case "integer": + if _, ok := toFloat(v); !ok { + return false + } + return isInteger(v) + case "array": + _, ok := v.([]any) + return ok + case "object": + _, ok := v.(map[string]any) + return ok + } + return true +} + +func toFloat(v any) (float64, bool) { + switch n := v.(type) { + case int: + return float64(n), true + case int8: + return float64(n), true + case int16: + return float64(n), true + case int32: + return float64(n), true + case int64: + return float64(n), true + case uint: + return float64(n), true + case uint8: + return float64(n), true + case uint16: + return float64(n), true + case uint32: + return float64(n), true + case uint64: + return float64(n), true + case float32: + return float64(n), true + case float64: + return n, true + } + return 0, false +} + +func isInteger(v any) bool { + switch n := v.(type) { + case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64: + return true + case float32: + f := float64(n) + return f == float64(int64(f)) + case float64: + return n == float64(int64(n)) + } + return false +} + +// enumContains returns true when v matches one of the enum values, with +// numeric coercion (int↔float) so that JSON's float64-default doesn't reject +// an enum authored in YAML as plain integers. +func enumContains(opts []any, v any) bool { + for _, opt := range opts { + if valuesEqual(opt, v) { + return true + } + } + return false +} + +func valuesEqual(a, b any) bool { + af, aok := toFloat(a) + bf, bok := toFloat(b) + if aok && bok { + return af == bf + } + return reflect.DeepEqual(a, b) +} + +// --- JSON Pointer (RFC 6901) ------------------------------------------------- + +func ptrPush(path, segment string) string { + return path + "/" + ptrEnc(segment) +} + +func ptrPushIdx(path string, idx int) string { + return path + "/" + strconv.Itoa(idx) +} + +func ptrEnc(s string) string { + s = strings.ReplaceAll(s, "~", "~0") + s = strings.ReplaceAll(s, "/", "~1") + return s +} + +// --- Message formatting ------------------------------------------------------ + +func typeMessage(want string, got any) string { + return "expected " + want + ", got " + typeName(got) +} + +func typeName(v any) string { + switch v.(type) { + case nil: + return "null" + case bool: + return "boolean" + case string: + return "string" + case []any: + return "array" + case map[string]any: + return "object" + } + if _, ok := toFloat(v); ok { + return "number" + } + return "unknown" +} + +func minLenMsg(n int) string { return "must be at least " + strconv.Itoa(n) + " characters" } +func maxLenMsg(n int) string { return "must be at most " + strconv.Itoa(n) + " characters" } + +func numStr(f float64) string { + return strconv.FormatFloat(f, 'g', -1, 64) +} diff --git a/zddc/internal/zddc/writer.go b/zddc/internal/zddc/writer.go index 57ac78e..a6786a8 100644 --- a/zddc/internal/zddc/writer.go +++ b/zddc/internal/zddc/writer.go @@ -8,43 +8,26 @@ import ( "gopkg.in/yaml.v3" ) -// WriteFile atomically writes zf as YAML to /.zddc. +// WriteAtomic writes data to absPath atomically. The parent directory is +// created (mode 0o755) if it does not exist; the file is written with mode +// 0o644. // -// The YAML round-trips through Marshal then Unmarshal as a sanity check — -// this catches struct-encoding bugs before they hit disk and ensures the -// file we produce is parseable by ParseFile (which is what every reader -// uses). On any failure the original file is untouched. -// -// Atomicity: the encoded bytes are written to a sibling temp file, fsync'd, -// and renamed onto the target. The cache for dirPath (and descendants) is -// invalidated after the rename so the next EffectivePolicy call reads -// fresh content. -func WriteFile(dirPath string, zf ZddcFile) error { - dirPath = filepath.Clean(dirPath) - if err := os.MkdirAll(dirPath, 0o755); err != nil { +// Implementation: bytes go to a sibling temp file, are fsync'd, then renamed +// onto absPath. On any failure the temp is removed and absPath is untouched. +// Knows nothing about caches — callers that need cache invalidation +// (.zddc, the apps cascade, etc.) handle it themselves. +func WriteAtomic(absPath string, data []byte) error { + dir := filepath.Dir(absPath) + if err := os.MkdirAll(dir, 0o755); err != nil { return fmt.Errorf("ensure dir: %w", err) } - data, err := yaml.Marshal(&zf) - if err != nil { - return fmt.Errorf("marshal: %w", err) - } - - // Sanity round-trip: re-parse what we just produced. If this fails the - // in-memory struct does not survive a write/read cycle and we should - // abort before touching disk. - var probe ZddcFile - if err := yaml.Unmarshal(data, &probe); err != nil { - return fmt.Errorf("round-trip parse: %w", err) - } - - target := filepath.Join(dirPath, ".zddc") - tmp, err := os.CreateTemp(dirPath, ".zddc.*.tmp") + base := filepath.Base(absPath) + tmp, err := os.CreateTemp(dir, "."+base+".*.tmp") if err != nil { return fmt.Errorf("create temp: %w", err) } tmpPath := tmp.Name() - // Best-effort cleanup if anything below fails. defer func() { _ = os.Remove(tmpPath) }() @@ -63,9 +46,42 @@ func WriteFile(dirPath string, zf ZddcFile) error { if err := os.Chmod(tmpPath, 0o644); err != nil { return fmt.Errorf("chmod temp: %w", err) } - if err := os.Rename(tmpPath, target); err != nil { + if err := os.Rename(tmpPath, absPath); err != nil { return fmt.Errorf("rename: %w", err) } + return nil +} + +// WriteFile atomically writes zf as YAML to /.zddc. +// +// The YAML round-trips through Marshal then Unmarshal as a sanity check — +// this catches struct-encoding bugs before they hit disk and ensures the +// file we produce is parseable by ParseFile (which is what every reader +// uses). On any failure the original file is untouched. +// +// After the write succeeds the policy and scan caches for dirPath (and +// descendants) are invalidated so the next EffectivePolicy / ScanZddcFiles +// call reads fresh content. +func WriteFile(dirPath string, zf ZddcFile) error { + dirPath = filepath.Clean(dirPath) + + data, err := yaml.Marshal(&zf) + if err != nil { + return fmt.Errorf("marshal: %w", err) + } + + // Sanity round-trip: re-parse what we just produced. If this fails the + // in-memory struct does not survive a write/read cycle and we should + // abort before touching disk. + var probe ZddcFile + if err := yaml.Unmarshal(data, &probe); err != nil { + return fmt.Errorf("round-trip parse: %w", err) + } + + target := filepath.Join(dirPath, ".zddc") + if err := WriteAtomic(target, data); err != nil { + return err + } InvalidateCache(dirPath) InvalidateScanCache()