ZDDC/zddc/internal/zddc/validate.go
ZDDC 8b6a2dc3e3 feat(zddc-server): apps fetch+cache subsystem with cascade overrides
Adds internal/apps/ package serving the five tool HTMLs at virtual paths
based on the surrounding folder name convention:

  archive      every directory (multi-project, project, archive, vendor)
  classifier   any Incoming/Working/Staging directory and subtree
  mdedit       any Working directory and subtree
  transmittal  any Staging directory and subtree
  landing      only at deployment root

The current-stable build of every tool is //go:embed'd into the binary
at compile time — that's the default with zero config. Operators
override per-directory via .zddc apps: entries; closer-to-leaf wins.

Spec syntax (in any apps: value):

  stable / beta / alpha / :stable          channel
  v0.0.4 / v0.0 / v0 / :v0.0.4              version
  https://my-mirror/releases                URL prefix only
  https://my-mirror/releases:beta           URL prefix + channel
  https://my-fork/archive.html              terminal full URL
  ./local.html / /abs/path.html             terminal local path

The special apps.default key provides a baseline URL prefix and channel
inherited by any app not overridden per-name. Per-axis cascade: a deeper
.zddc can override the URL, the channel, or both.

Cascade walks root→leaf; default applies first at each level, then the
per-app entry. Terminal sources (paths and full .html URLs) short-circuit
composition; deeper non-terminal entries override parent terminals.

URL sources fetch once on first request and cache forever in
<ZDDC_ROOT>/_app/<host>/<path> — different upstreams with the same
filename stay distinct. No background refresh, no SHA-256 verification:
operators delete the cache file to force a refetch. Concurrent misses
for the same source dedupe via a 30-line hand-rolled singleflight.

Per-request override: any user can append ?v=<spec> to a tool URL
(e.g. ?v=beta, ?v=v0.0.4, ?v=:alpha, ?v=https://mirror/releases:beta)
to ask for a different build for one request. Security: ?v= serves
ONLY versions already in the cache (cache miss returns 404; path
sources are rejected outright with 400). Users cannot trigger
arbitrary upstream fetches via crafted URLs.

Failed URL fetches (network down, 5xx) fall back to embedded with a
one-time WARN log. The X-ZDDC-Source response header reports what
served: fetch:URL / cache:URL / path:/abs / embedded:<app>@<build>.

Wire-in (cmd/zddc-server/main.go): dispatch routes <dir>/<app>.html
through apps.MatchAppHTML + AppAvailableAt + apps.Server.Serve when
no real file exists. Direct URL access to /_app/... is blocked at
the dispatch layer — cached files must go through the apps resolver
so they get correct Content-Type and ACL gating.

Schema (internal/zddc/file.go): ZddcFile gains Apps map[string]string
for cascade overrides. Validator (internal/zddc/validate.go) accepts
the special "default" key alongside the five canonical app names and
all spec forms.

Removes ZDDC_APPS_* env vars (no admin UI, no refresh interval, no
upstream allow-list — the simpler model has fewer knobs).

40+ unit tests across the new package: parser shapes, cascade
resolution with default+per-app interactions, terminal short-circuit
semantics, ?v= cache-only enforcement, embedded fallback, atomic
cache writes, singleflight dedup. Plus end-to-end dispatch tests in
cmd/zddc-server/main_test.go.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:25:25 -05:00

247 lines
7.6 KiB
Go

package zddc
import (
"fmt"
"strings"
)
// AppNames is the canonical set of app HTML files the server resolves
// via the apps fetch+cache subsystem. Order is stable for reproducible
// admin-UI rendering.
var AppNames = []string{"archive", "transmittal", "classifier", "mdedit", "landing"}
// AppsDefaultKey is the special apps-map key that provides the baseline
// URL prefix and channel for any app not overridden per-name. Cascades
// through .zddc files like a per-app entry.
const AppsDefaultKey = "default"
// IsKnownApp reports whether name is one of the canonical apps.
func IsKnownApp(name string) bool {
for _, n := range AppNames {
if n == name {
return true
}
}
return false
}
// IsValidAppsKey reports whether name is acceptable as a key in the
// `apps:` map — either a canonical app or the special "default" key.
func IsValidAppsKey(name string) bool {
return name == AppsDefaultKey || IsKnownApp(name)
}
// ValidatePattern returns an error if pattern is not a syntactically
// well-formed email-glob. The matcher in MatchesPattern is forgiving and
// will silently fail to match malformed patterns (e.g., "alice@@x" or
// patterns with spaces); we want explicit rejection at write time so a
// human typo shows up as a 400 instead of "your rule mysteriously does
// nothing".
//
// Rules:
// - non-empty after trimming surrounding whitespace
// - no internal whitespace
// - at most one '@'
// - if an '@' is present, both local and domain parts are non-empty
// - the bare token "@" is rejected (covered by the non-empty parts rule)
func ValidatePattern(pattern string) error {
if pattern == "" {
return fmt.Errorf("pattern is empty")
}
if strings.TrimSpace(pattern) != pattern {
return fmt.Errorf("pattern has leading or trailing whitespace")
}
if strings.ContainsAny(pattern, " \t\n\r") {
return fmt.Errorf("pattern contains whitespace")
}
at := strings.Count(pattern, "@")
if at > 1 {
return fmt.Errorf("pattern contains more than one '@'")
}
if at == 1 {
parts := strings.SplitN(pattern, "@", 2)
if parts[0] == "" {
return fmt.Errorf("pattern has empty local part before '@'")
}
if parts[1] == "" {
return fmt.Errorf("pattern has empty domain part after '@'")
}
}
return nil
}
// ValidateFile returns a slice of (field, message) errors for any
// malformed entry in zf. An empty slice means the file is acceptable to
// write. The returned errors include enough field context that the editor
// can render them next to the offending row.
type FieldError struct {
Field string `json:"field"`
Message string `json:"message"`
}
// ValidateProjectName returns an error if name is not acceptable as a new
// directory name created under cfg.Root. The rules mirror the reserved-prefix
// policy enforced elsewhere (resolvePath, ScanZddcFiles, ServeProjectList) so
// a project created here is enumerable by the same listing code.
//
// Rules:
// - length 1..64
// - first char alphanumeric (rejects leading '.' and '_', matching the
// hidden-segment convention)
// - subsequent chars alphanumeric, '-', or '_'
// - rejects path separators, whitespace, and any '.' anywhere (so "..",
// ".hidden", "foo.bar" all fail — directory names stay flat)
func ValidateProjectName(name string) error {
if name == "" {
return fmt.Errorf("name is empty")
}
if len(name) > 64 {
return fmt.Errorf("name exceeds 64 characters")
}
for i, r := range name {
switch {
case r >= 'A' && r <= 'Z':
case r >= 'a' && r <= 'z':
case r >= '0' && r <= '9':
case (r == '-' || r == '_') && i > 0:
// allowed in the body, not as the leading character
default:
return fmt.Errorf("name contains invalid character %q at position %d", r, i)
}
}
return nil
}
// ValidateAppSourceSpec returns nil if spec is a syntactically well-formed
// source spec accepted by apps.ParseSpec. It checks the string shape only —
// it does not verify URLs are reachable or paths exist.
//
// Accepted forms:
// - "stable" / "beta" / "alpha" / ":stable" / ":beta" / ":alpha" (channel)
// - "v0.0.4" / "0.0.4" / "v0.0" / "0.0" / "v0" / "0" / ":v0.0.4" (version)
// - "https://host/path" (URL prefix)
// - "https://host/path:stable" (URL prefix + channel)
// - "https://host/path/file.html" (terminal full URL)
// - "/abs/path.html" / "./rel/path.html" / "../sibling.html" (path)
func ValidateAppSourceSpec(spec string) error {
if spec == "" {
return fmt.Errorf("source spec is empty")
}
if strings.ContainsAny(spec, " \t\n\r") {
return fmt.Errorf("source spec contains whitespace")
}
// Path forms.
if strings.HasPrefix(spec, "/") ||
strings.HasPrefix(spec, "./") ||
strings.HasPrefix(spec, "../") {
return nil
}
// URL forms.
if strings.HasPrefix(spec, "https://") || strings.HasPrefix(spec, "http://") {
return validateURLSpec(spec)
}
// Channel-or-version (with optional leading colon).
chanPart := strings.TrimPrefix(spec, ":")
if chanPart == "" {
return fmt.Errorf("empty channel after ':'")
}
return validateChannelOrVersion(chanPart)
}
// validateURLSpec checks the URL-prefix or full-URL form. Splits on the
// last `:` after the last `/` (matching apps.parseURLSpec behavior).
func validateURLSpec(spec string) error {
// Minimal sanity check on URL shape.
if len(spec) <= len("https://") {
return fmt.Errorf("URL is missing host")
}
lastSlash := strings.LastIndex(spec, "/")
if lastSlash < 0 {
return fmt.Errorf("invalid URL %q: missing path separator", spec)
}
afterSlash := spec[lastSlash+1:]
colonInTail := strings.LastIndex(afterSlash, ":")
urlPart, suffixPart := spec, ""
if colonInTail >= 0 {
urlPart = spec[:lastSlash+1+colonInTail]
suffixPart = afterSlash[colonInTail+1:]
}
if strings.HasSuffix(urlPart, ".html") {
if suffixPart != "" {
return fmt.Errorf("URL ends in .html but has %q suffix", ":"+suffixPart)
}
return nil // terminal full URL
}
if suffixPart != "" {
return validateChannelOrVersion(suffixPart)
}
return nil // URL-prefix only
}
// validateChannelOrVersion enforces the channel/version shape.
func validateChannelOrVersion(s string) error {
if s == "stable" || s == "beta" || s == "alpha" {
return nil
}
rest := strings.TrimPrefix(s, "v")
if rest == "" {
return fmt.Errorf("unrecognized source spec %q", s)
}
parts := strings.Split(rest, ".")
if len(parts) > 3 {
return fmt.Errorf("version has too many dots: %q", s)
}
for _, p := range parts {
if p == "" {
return fmt.Errorf("version has empty component: %q", s)
}
for _, r := range p {
if r < '0' || r > '9' {
return fmt.Errorf("unrecognized source spec %q", s)
}
}
}
return nil
}
func ValidateFile(zf ZddcFile) []FieldError {
var errs []FieldError
check := func(field string, vals []string) {
for i, v := range vals {
if err := ValidatePattern(v); err != nil {
errs = append(errs, FieldError{
Field: fmt.Sprintf("%s[%d]", field, i),
Message: err.Error(),
})
}
}
}
check("acl.allow", zf.ACL.Allow)
check("acl.deny", zf.ACL.Deny)
check("admins", zf.Admins)
if len(zf.Title) > 200 {
errs = append(errs, FieldError{
Field: "title",
Message: "title exceeds 200 characters",
})
}
for app, spec := range zf.Apps {
if !IsValidAppsKey(app) {
errs = append(errs, FieldError{
Field: fmt.Sprintf("apps.%s", app),
Message: fmt.Sprintf("unknown app %q (known: default, archive, transmittal, classifier, mdedit, landing)", app),
})
continue
}
if err := ValidateAppSourceSpec(spec); err != nil {
errs = append(errs, FieldError{
Field: fmt.Sprintf("apps.%s", app),
Message: err.Error(),
})
}
}
return errs
}