Replaces the URL/channel/version-fetching tool-HTML system with a
local-only override model. No network fetch, no Ed25519 signatures, no
channels/versions, no `apps:` .zddc key.
Tool HTML resolves, in precedence:
1. a real file on disk at the path (operator drops browse.html / archive.html
/ a new mytool.html) — served by the existing static handler;
2. an `<app>.html` member of the site-root <ZDDC_ROOT>/.zddc.zip bundle, read
server-side via internal/zipfs (local file, no fetch, no signature;
re-stat'd each request for free hot-reload);
3. the embedded //go:embed default.
Remove (complete unwire):
- internal/apps/{fetch,verify,cache,singleflight}.go and their tests; the
spec-parsing/cascade machinery in apps.go (ParseSpec/Resolve/PreviewLine/
SpecComponents/appsState, DefaultUpstream*/DefaultChannel/CacheDirName).
- --apps-pubkey / ZDDC_APPS_PUBKEY flag+env+Config field; the setupApps
cache/fetcher/pubkey wiring (now just apps.NewServer(root, version)).
- the `apps:` / `apps_pubkey:` .zddc keys: ZddcFile.Apps/AppsPubKey, the
walker merges, cascade-summary adds, validate.go apps validation
(ValidateAppSourceSpec/validateURLSpec/validateChannelOrVersion/
AppsDefaultKey/IsValidAppsKey), and the isZero/is-empty refs. A stale
apps:/apps_pubkey: in an existing .zddc is now silently ignored
(back-compat), not a parse error. Client .zddc validator (preview-yaml.js)
drops the apps/apps_pubkey keys + appsmap case.
Add:
- internal/apps/bundle.go — nil-safe Bundle over <root>/.zddc.zip with
stat-based hot-reload, size caps, corrupt-zip tolerance.
- handler.go: Server{Bundle}, resolveBytes (bundle→embedded), simplified
Serve; X-ZDDC-Source = bundle:<m> / embedded:<app>@<ver>.
- dispatch: GET /.zddc.zip is 404 for everyone (config, not content); the
server reads members from the filesystem internally.
Tests: new bundle_test.go (member hit/absent/no-file/hot-reload/corrupt);
handler_test.go rewritten for bundle-overrides-embedded, absent-member→
embedded, unknown-tool 503, conditional-GET for both sources; dispatch test
covers bundle override + /.zddc.zip 404 + availability rules. go build/vet/
test ./... all green; gofmt clean. Docs (AGENTS.md, ARCHITECTURE.md) updated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
292 lines
8 KiB
Go
292 lines
8 KiB
Go
package zddc
|
|
|
|
import (
|
|
"strings"
|
|
)
|
|
|
|
// matchGlob looks up a path segment in a paths: map. Literal
|
|
// (case-insensitive) match first; falls back to a "*" segment-
|
|
// wildcard key if present. Returns nil when neither hits.
|
|
//
|
|
// Phase 2: single-segment globs only. Multi-segment keys (a/b) are
|
|
// rejected at the schema level and never reach this lookup.
|
|
func matchGlob(m map[string]ZddcFile, seg string) *ZddcFile {
|
|
if m == nil {
|
|
return nil
|
|
}
|
|
// Fast path: exact key match (case-sensitive — operator-controlled).
|
|
if v, ok := m[seg]; ok {
|
|
return &v
|
|
}
|
|
// Case-insensitive literal match for canonical-folder ergonomics
|
|
// (operator writes `archive:`; on-disk dir may be `Archive`).
|
|
lower := strings.ToLower(seg)
|
|
for k, v := range m {
|
|
if k == "*" {
|
|
continue
|
|
}
|
|
if strings.ToLower(k) == lower {
|
|
return &v
|
|
}
|
|
}
|
|
// Wildcard fallback.
|
|
if v, ok := m["*"]; ok {
|
|
return &v
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// mergeOverlay composes two ZddcFile values into one. `top` overrides
|
|
// `base` per-field. Maps merge key-by-key (top wins on key clash);
|
|
// scalar fields take top's value when non-zero (allowing base to fill
|
|
// in unset fields).
|
|
//
|
|
// The intended use is a stack of contributions from lowest to highest
|
|
// specificity, applied in order:
|
|
//
|
|
// merged = empty
|
|
// for c in ancestor_virtual_contributions { // lowest specificity first
|
|
// merged = mergeOverlay(merged, c)
|
|
// }
|
|
// merged = mergeOverlay(merged, on_disk_at_this_level)
|
|
//
|
|
// Each successive overlay overrides what came before for the same key.
|
|
func mergeOverlay(base, top ZddcFile) ZddcFile {
|
|
out := base
|
|
|
|
if top.Title != "" {
|
|
out.Title = top.Title
|
|
}
|
|
if top.CreatedBy != "" {
|
|
out.CreatedBy = top.CreatedBy
|
|
}
|
|
if top.Inherit != nil {
|
|
out.Inherit = top.Inherit
|
|
}
|
|
if top.DefaultTool != "" {
|
|
out.DefaultTool = top.DefaultTool
|
|
}
|
|
if top.DirTool != "" {
|
|
out.DirTool = top.DirTool
|
|
}
|
|
if top.AutoOwn != nil {
|
|
out.AutoOwn = top.AutoOwn
|
|
}
|
|
if top.AutoOwnFenced != nil {
|
|
out.AutoOwnFenced = top.AutoOwnFenced
|
|
}
|
|
// AutoOwnRoles: presence (non-nil) overrides; a deeper level
|
|
// declaring an empty list replaces (and explicitly suppresses)
|
|
// the ancestor's role list. This matches the leaf-wins semantic
|
|
// for the other path-tree contribution lists.
|
|
if top.AutoOwnRoles != nil {
|
|
out.AutoOwnRoles = top.AutoOwnRoles
|
|
}
|
|
if top.DropTarget != nil {
|
|
out.DropTarget = top.DropTarget
|
|
}
|
|
if top.PartySource != "" {
|
|
out.PartySource = top.PartySource
|
|
}
|
|
if top.History != nil {
|
|
out.History = top.History
|
|
}
|
|
if len(top.HistoryGlobs) > 0 {
|
|
out.HistoryGlobs = top.HistoryGlobs
|
|
}
|
|
// Worm: presence (non-nil, even empty) marks the WORM zone.
|
|
// Concat-dedupe across levels (a deeper .zddc adds controllers);
|
|
// preserve a non-nil empty slice so `worm: []` survives the
|
|
// overlay.
|
|
if top.Worm != nil {
|
|
out.Worm = mergeStringSlicePreserveEmpty(out.Worm, top.Worm)
|
|
}
|
|
if top.Virtual != nil {
|
|
out.Virtual = top.Virtual
|
|
}
|
|
if top.ReceivedPath != "" {
|
|
out.ReceivedPath = top.ReceivedPath
|
|
}
|
|
if top.PlannedReviewDate != "" {
|
|
out.PlannedReviewDate = top.PlannedReviewDate
|
|
}
|
|
if top.PlannedResponseDate != "" {
|
|
out.PlannedResponseDate = top.PlannedResponseDate
|
|
}
|
|
out.AvailableTools = mergeStringSlice(out.AvailableTools, top.AvailableTools)
|
|
|
|
out.Admins = mergeStringSlice(out.Admins, top.Admins)
|
|
if top.ACL.Inherit != nil {
|
|
out.ACL.Inherit = top.ACL.Inherit
|
|
}
|
|
|
|
out.ACL.Permissions = mergeStringMap(out.ACL.Permissions, top.ACL.Permissions)
|
|
out.Tables = mergeStringMap(out.Tables, top.Tables)
|
|
out.Display = mergeStringMap(out.Display, top.Display)
|
|
|
|
// Convert: per-key latest-wins. Pointer-to-struct so we can tell
|
|
// "absent" from "explicitly empty" — the latter is rare but valid
|
|
// (an operator who wants to suppress a deployment-default value).
|
|
// Empty top values do NOT clear the ancestor value; operators must
|
|
// set an explicit non-empty string to override.
|
|
if top.Convert != nil {
|
|
if out.Convert == nil {
|
|
out.Convert = &ConvertMetadata{}
|
|
}
|
|
if top.Convert.Client != "" {
|
|
out.Convert.Client = top.Convert.Client
|
|
}
|
|
if top.Convert.Project != "" {
|
|
out.Convert.Project = top.Convert.Project
|
|
}
|
|
if top.Convert.Contractor != "" {
|
|
out.Convert.Contractor = top.Convert.Contractor
|
|
}
|
|
if top.Convert.ProjectNumber != "" {
|
|
out.Convert.ProjectNumber = top.Convert.ProjectNumber
|
|
}
|
|
}
|
|
|
|
// Roles: per-name merge (top wins on name clash). This combines
|
|
// the on-disk .zddc at a level with any virtual contributions
|
|
// from ancestor paths: at the same level. Cross-LEVEL role
|
|
// membership union (and the reset flag) is handled at lookup
|
|
// time by lookupRoleMembers, not here.
|
|
if len(top.Roles) > 0 {
|
|
if out.Roles == nil {
|
|
out.Roles = make(map[string]Role, len(top.Roles))
|
|
} else {
|
|
merged := make(map[string]Role, len(out.Roles)+len(top.Roles))
|
|
for k, v := range out.Roles {
|
|
merged[k] = v
|
|
}
|
|
out.Roles = merged
|
|
}
|
|
for k, v := range top.Roles {
|
|
out.Roles[k] = v
|
|
}
|
|
}
|
|
|
|
// FieldCodes: map-merge keyed by field name. Top wins on key
|
|
// clash — a sub-tree can narrow or replace a single code's
|
|
// vocabulary without dropping unrelated codes. Mirror of Apps.
|
|
if len(top.FieldCodes) > 0 {
|
|
if out.FieldCodes == nil {
|
|
out.FieldCodes = make(map[string]FieldCode, len(top.FieldCodes))
|
|
} else {
|
|
merged := make(map[string]FieldCode, len(out.FieldCodes)+len(top.FieldCodes))
|
|
for k, v := range out.FieldCodes {
|
|
merged[k] = v
|
|
}
|
|
out.FieldCodes = merged
|
|
}
|
|
for k, v := range top.FieldCodes {
|
|
out.FieldCodes[k] = v
|
|
}
|
|
}
|
|
|
|
// Records: map-merge keyed by filename pattern. Each entry's
|
|
// inner fields merge via mergeRecordRule (scalars overwrite,
|
|
// FieldDefaults map-merge, Locked concat-dedupe). Two different
|
|
// patterns at different cascade levels coexist as independent
|
|
// entries; identical patterns merge their contents.
|
|
if len(top.Records) > 0 {
|
|
if out.Records == nil {
|
|
out.Records = make(map[string]RecordRule, len(top.Records))
|
|
} else {
|
|
merged := make(map[string]RecordRule, len(out.Records)+len(top.Records))
|
|
for k, v := range out.Records {
|
|
merged[k] = v
|
|
}
|
|
out.Records = merged
|
|
}
|
|
for k, v := range top.Records {
|
|
out.Records[k] = mergeRecordRule(out.Records[k], v)
|
|
}
|
|
}
|
|
|
|
// Paths: top entirely replaces base if set. Recursive descent of
|
|
// the walker is what threads ancestor Paths through to the right
|
|
// level — merging Paths maps themselves at this layer would
|
|
// double-apply.
|
|
if len(top.Paths) > 0 {
|
|
out.Paths = top.Paths
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
func mergeStringMap(base, top map[string]string) map[string]string {
|
|
if len(top) == 0 {
|
|
return base
|
|
}
|
|
if len(base) == 0 {
|
|
out := make(map[string]string, len(top))
|
|
for k, v := range top {
|
|
out[k] = v
|
|
}
|
|
return out
|
|
}
|
|
out := make(map[string]string, len(base)+len(top))
|
|
for k, v := range base {
|
|
out[k] = v
|
|
}
|
|
for k, v := range top {
|
|
out[k] = v
|
|
}
|
|
return out
|
|
}
|
|
|
|
// mergeStringSlicePreserveEmpty is mergeStringSlice but always returns
|
|
// a non-nil result when top is non-nil — so an empty `worm: []` in a
|
|
// .zddc still marks the WORM zone after the overlay. Caller is
|
|
// expected to only invoke this when top != nil.
|
|
func mergeStringSlicePreserveEmpty(base, top []string) []string {
|
|
seen := make(map[string]struct{}, len(base)+len(top))
|
|
out := make([]string, 0, len(base)+len(top))
|
|
for _, v := range base {
|
|
if _, ok := seen[v]; ok {
|
|
continue
|
|
}
|
|
seen[v] = struct{}{}
|
|
out = append(out, v)
|
|
}
|
|
for _, v := range top {
|
|
if _, ok := seen[v]; ok {
|
|
continue
|
|
}
|
|
seen[v] = struct{}{}
|
|
out = append(out, v)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func mergeStringSlice(base, top []string) []string {
|
|
if len(top) == 0 {
|
|
return base
|
|
}
|
|
if len(base) == 0 {
|
|
out := make([]string, len(top))
|
|
copy(out, top)
|
|
return out
|
|
}
|
|
// Concatenate with dedupe (preserve order: base first, then
|
|
// top entries that weren't already in base).
|
|
seen := make(map[string]struct{}, len(base)+len(top))
|
|
out := make([]string, 0, len(base)+len(top))
|
|
for _, v := range base {
|
|
if _, ok := seen[v]; ok {
|
|
continue
|
|
}
|
|
seen[v] = struct{}{}
|
|
out = append(out, v)
|
|
}
|
|
for _, v := range top {
|
|
if _, ok := seen[v]; ok {
|
|
continue
|
|
}
|
|
seen[v] = struct{}{}
|
|
out = append(out, v)
|
|
}
|
|
return out
|
|
}
|