ZDDC/zddc/internal/zddc/validate.go
ZDDC b1479c5104 feat(zddc-server): include browse/form/tables in apps cascade
Wires up live alpha-dev iteration on bitnest. With this change a
`.zddc apps: <tool>: <path>` entry overrides the embedded copy for any
of the eight tools, not just five.

Two coupled fixes:

  1. zddc.AppNames had a five-entry list (archive/transmittal/
     classifier/mdedit/landing) — predating browse/form/tables.
     ResolveWithOverride's `if !IsKnownApp(app)` gate silently rejected
     those three before ever looking at the cascade, falling back to
     embedded with an "unknown app" error.

  2. handler.ServeDirectory hard-coded `apps.EmbeddedBytes("browse")`
     for the HTML directory-listing fallback, bypassing the apps
     subsystem entirely. Now takes an optional *apps.Server and
     delegates to appsSrv.Serve(w, r, "browse", chain, absDir) when
     wired, so the cascade is honored at bare directory URLs too
     (the most common way browse gets surfaced).

Both call sites in main.go and the test signatures in
directory_test.go updated. ValidateFile error message now lists all
eight known apps.

Verified end-to-end on bitnest with a root .zddc apps cascade
pointing at /srv/.zddc.d/source/<tool>/dist/<file>: every `./build`
on the host is now immediately visible after a hard refresh. Iteration
loop is `./build` (or `sh tool/build.sh`) then reload — no container
restart needed, since the apps subsystem reads the path source on
each request.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 12:09:24 -05:00

252 lines
7.9 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.
//
// All eight HTML tools belong here — including browse, form, and tables.
// Omitting any of them means the apps cascade (.zddc apps:) silently
// short-circuits to embedded for that name, defeating live-dev
// path-source overrides.
var AppNames = []string{"archive", "transmittal", "classifier", "mdedit", "landing", "browse", "form", "tables"}
// 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, browse, form, tables)", app),
})
continue
}
if err := ValidateAppSourceSpec(spec); err != nil {
errs = append(errs, FieldError{
Field: fmt.Sprintf("apps.%s", app),
Message: err.Error(),
})
}
}
return errs
}