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 seven 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. // // Markdown editing used to be a dedicated tool ("mdedit"); it now // lives as a plugin inside browse (browse/js/preview-markdown.js). var AppNames = []string{"archive", "transmittal", "classifier", "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(), }) } } } for principal := range zf.ACL.Permissions { if err := ValidatePattern(principal); err != nil { errs = append(errs, FieldError{ Field: fmt.Sprintf("acl.permissions[%q]", principal), Message: err.Error(), }) } } 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, 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(), }) } } // worm: is a list of principal patterns (email-globs, @role:name, // or bare role names) that get write-once-create inside the WORM // zone. Validate each as an email-glob unless it's a role // reference (role names are validated by the role machinery). for i, principal := range zf.Worm { if strings.HasPrefix(principal, "@role:") { continue // role refs validated elsewhere } // A bare name with no "@" could be a role name; ValidatePattern // accepts it (no @, no whitespace), and MatchesPrincipal // resolves it as a role if one is defined. So this only // rejects genuinely malformed entries (whitespace, double @). if err := ValidatePattern(principal); err != nil { errs = append(errs, FieldError{ Field: fmt.Sprintf("worm[%d]", i), Message: err.Error(), }) } } return errs }