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>
This commit is contained in:
ZDDC 2026-05-01 15:25:25 -05:00
parent 9b3b11fc20
commit 8b6a2dc3e3
23 changed files with 38478 additions and 10 deletions

View file

@ -12,6 +12,7 @@ import (
"syscall" "syscall"
"time" "time"
"codeberg.org/VARASYS/ZDDC/zddc/internal/apps"
"codeberg.org/VARASYS/ZDDC/zddc/internal/archive" "codeberg.org/VARASYS/ZDDC/zddc/internal/archive"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config" "codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/handler" "codeberg.org/VARASYS/ZDDC/zddc/internal/handler"
@ -40,6 +41,13 @@ func main() {
} }
slog.Info("archive index built", "duration", time.Since(start)) slog.Info("archive index built", "duration", time.Since(start))
// Apps fetch+cache subsystem.
appsServer, err := setupApps(cfg)
if err != nil {
slog.Error("failed to set up apps subsystem", "err", err)
os.Exit(1)
}
// TLS config // TLS config
tlsCfg, useTLS, err := tlsutil.TLSConfig(cfg) tlsCfg, useTLS, err := tlsutil.TLSConfig(cfg)
if err != nil { if err != nil {
@ -63,6 +71,7 @@ func main() {
}() }()
} }
// HTTP handler // HTTP handler
mux := http.NewServeMux() mux := http.NewServeMux()
// Middleware chain (outermost → innermost): // Middleware chain (outermost → innermost):
@ -78,7 +87,7 @@ func main() {
// CORSMiddleware — Origin / preflight handling. // CORSMiddleware — Origin / preflight handling.
// dispatch — the actual request handler. // dispatch — the actual request handler.
mux.Handle("/", handler.ACLMiddleware(cfg, handler.AccessLogMiddleware(handler.CORSMiddleware(cfg, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { mux.Handle("/", handler.ACLMiddleware(cfg, handler.AccessLogMiddleware(handler.CORSMiddleware(cfg, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
dispatch(cfg, idx, logRing, w, r) dispatch(cfg, idx, logRing, appsServer, w, r)
}))))) })))))
srv := &http.Server{ srv := &http.Server{
@ -117,8 +126,20 @@ func main() {
slog.Info("stopped") slog.Info("stopped")
} }
// setupApps creates the cache + fetcher + server. No seeding, no refresh,
// no admin UI — the server fetches once on first request, caches forever
// in <ZDDC_ROOT>/_app/, and falls back to the embedded HTML on any failure.
func setupApps(cfg config.Config) (*apps.Server, error) {
cache, err := apps.NewCache(filepath.Join(cfg.Root, apps.CacheDirName))
if err != nil {
return nil, fmt.Errorf("create cache: %w", err)
}
fetcher := apps.NewFetcher(cache, slog.Default())
return apps.NewServer(cfg.Root, cache, fetcher, cfg.BuildVersion), nil
}
// dispatch routes a request to the appropriate handler. // dispatch routes a request to the appropriate handler.
func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, w http.ResponseWriter, r *http.Request) { func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, appsSrv *apps.Server, w http.ResponseWriter, r *http.Request) {
urlPath := r.URL.Path urlPath := r.URL.Path
email := handler.EmailFromContext(r) email := handler.EmailFromContext(r)
@ -150,8 +171,19 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, w ht
// the same Azure Files PVC as served data) cannot be fetched. The // the same Azure Files PVC as served data) cannot be fetched. The
// recognized virtual prefixes (.profile handled above, cfg.IndexPath // recognized virtual prefixes (.profile handled above, cfg.IndexPath
// handled below) are explicitly allowed through. // handled below) are explicitly allowed through.
//
// Also reserve the apps cache directory (`_app`): the cached HTML files
// there must be served via the apps resolver (with proper headers and
// ACL), never raw at /_app/...html.
for _, seg := range segments { for _, seg := range segments {
if seg == "" || !strings.HasPrefix(seg, ".") { if seg == "" {
continue
}
if seg == apps.CacheDirName {
http.NotFound(w, r)
return
}
if !strings.HasPrefix(seg, ".") {
continue continue
} }
if seg == cfg.IndexPath { if seg == cfg.IndexPath {
@ -175,6 +207,25 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, w ht
} }
} }
// Apps resolution for the root landing path: GET / or /index.html with
// no real index.html on disk → serve via apps.Serve("landing"). The
// other four apps are caught by the "stat fails → app HTML?" branch
// below, which only triggers when no concrete file is at the URL path.
if appsSrv != nil && (urlPath == "/" || urlPath == "/index.html") {
realIndex := filepath.Join(cfg.Root, "index.html")
if _, err := os.Stat(realIndex); os.IsNotExist(err) {
chain, _ := zddc.EffectivePolicy(cfg.Root, cfg.Root)
if !zddc.AllowedWithChain(chain, email) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
if apps.AppAvailableAt(cfg.Root, cfg.Root, "landing") {
appsSrv.Serve(w, r, "landing", chain, cfg.Root)
return
}
}
}
// Resolve the physical path // Resolve the physical path
cleanPath := filepath.FromSlash(strings.TrimPrefix(urlPath, "/")) cleanPath := filepath.FromSlash(strings.TrimPrefix(urlPath, "/"))
absPath := filepath.Join(cfg.Root, cleanPath) absPath := filepath.Join(cfg.Root, cleanPath)
@ -189,6 +240,25 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, w ht
info, err := os.Stat(absPath) info, err := os.Stat(absPath)
if err != nil { if err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
// File doesn't exist at this path. If the URL matches one of
// the five canonical app HTML names AND the request directory
// is one where that app is available (Incoming/Working/Staging
// for classifier/mdedit/transmittal, anywhere for archive,
// root only for landing), resolve via the apps subsystem.
if appsSrv != nil {
if app, requestDirRel := apps.MatchAppHTML(urlPath); app != "" {
requestDir := filepath.Join(cfg.Root, filepath.FromSlash(requestDirRel))
if apps.AppAvailableAt(cfg.Root, requestDir, app) {
chain, _ := zddc.EffectivePolicy(cfg.Root, requestDir)
if !zddc.AllowedWithChain(chain, email) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
appsSrv.Serve(w, r, app, chain, requestDir)
return
}
}
}
http.Error(w, "Not Found", http.StatusNotFound) http.Error(w, "Not Found", http.StatusNotFound)
} else { } else {
http.Error(w, "Internal Server Error", http.StatusInternalServerError) http.Error(w, "Internal Server Error", http.StatusInternalServerError)

View file

@ -3,13 +3,17 @@ package main
import ( import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"codeberg.org/VARASYS/ZDDC/zddc/internal/apps"
"codeberg.org/VARASYS/ZDDC/zddc/internal/archive" "codeberg.org/VARASYS/ZDDC/zddc/internal/archive"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config" "codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/handler" "codeberg.org/VARASYS/ZDDC/zddc/internal/handler"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
) )
// TestDispatchHidesDotPrefixedSegments asserts the dispatch() guard that // TestDispatchHidesDotPrefixedSegments asserts the dispatch() guard that
@ -74,7 +78,7 @@ func TestDispatchHidesDotPrefixedSegments(t *testing.T) {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, tc.path, nil) req := httptest.NewRequest(http.MethodGet, tc.path, nil)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
dispatch(cfg, idx, ring, rec, req) dispatch(cfg, idx, ring, nil, rec, req)
if rec.Code != tc.wantStatus { if rec.Code != tc.wantStatus {
t.Errorf("path=%q status=%d want=%d body=%q", t.Errorf("path=%q status=%d want=%d body=%q",
tc.path, rec.Code, tc.wantStatus, rec.Body.String()) tc.path, rec.Code, tc.wantStatus, rec.Body.String())
@ -83,6 +87,117 @@ func TestDispatchHidesDotPrefixedSegments(t *testing.T) {
} }
} }
// TestDispatchAppsResolution drives the full apps fetch+cache flow through
// dispatch() with a fake upstream. Confirms that:
// - GET / serves the landing app from the apps subsystem
// - GET /archive.html serves the archive app via fetch+cache
// - second GET /archive.html serves from cache (X-ZDDC-Source: cache:)
// - direct URL access to /_zddc/... is rejected
func TestDispatchAppsResolution(t *testing.T) {
root := t.TempDir()
body := []byte("<!doctype html>archive content")
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("ETag", `"v1"`)
_, _ = w.Write(body)
}))
defer upstream.Close()
upstreamURL, _ := url.Parse(upstream.URL)
upstreamHost := upstreamURL.Host
if i := strings.Index(upstreamHost, ":"); i >= 0 {
upstreamHost = upstreamHost[:i]
}
_ = upstreamHost // referenced below
// Seed root .zddc with subdir-cascade Apps entries pointing at the
// fake upstream. Allow all email patterns (anonymous) so the test
// doesn't have to set up email headers.
zf := zddc.ZddcFile{
ACL: zddc.ACLRules{Allow: []string{"*"}},
Apps: map[string]string{
"archive": upstream.URL + "/archive_stable.html",
"transmittal": upstream.URL + "/transmittal_stable.html",
"classifier": upstream.URL + "/classifier_stable.html",
"mdedit": upstream.URL + "/mdedit_stable.html",
"landing": upstream.URL + "/landing_stable.html",
},
}
if err := zddc.WriteFile(root, zf); err != nil {
t.Fatalf("WriteFile: %v", err)
}
// Create folder convention dirs so classifier/mdedit/transmittal
// availability rules pass for the test paths used below.
mustMkdir(t, filepath.Join(root, "Project-A", "Working"))
idx, err := archive.BuildIndex(root)
if err != nil {
t.Fatalf("BuildIndex: %v", err)
}
cfg := config.Config{
Root: root,
IndexPath: ".archive",
EmailHeader: "X-Auth-Request-Email",
BuildVersion: "test-build",
}
ring := handler.NewLogRing(10)
appsSrv, err := setupApps(cfg)
if err != nil {
t.Fatalf("setupApps: %v", err)
}
// GET /archive.html → fetched from upstream (archive is available everywhere)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/archive.html", nil)
dispatch(cfg, idx, ring, appsSrv, rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("first /archive.html: status=%d body=%s", rec.Code, rec.Body.String())
}
if rec.Body.String() != string(body) {
t.Errorf("first /archive.html: body mismatch")
}
// GET /archive.html again → cache hit (no new upstream fetch)
rec2 := httptest.NewRecorder()
dispatch(cfg, idx, ring, appsSrv, rec2, httptest.NewRequest(http.MethodGet, "/archive.html", nil))
if rec2.Code != http.StatusOK {
t.Errorf("second /archive.html: status=%d", rec2.Code)
}
// GET / → landing
rec3 := httptest.NewRecorder()
dispatch(cfg, idx, ring, appsSrv, rec3, httptest.NewRequest(http.MethodGet, "/", nil))
if rec3.Code != http.StatusOK {
t.Errorf("GET /: status=%d", rec3.Code)
}
// Direct URL access to /_app/ → 404
rec4 := httptest.NewRecorder()
dispatch(cfg, idx, ring, appsSrv, rec4, httptest.NewRequest(http.MethodGet, "/_app/foo.html", nil))
if rec4.Code != http.StatusNotFound {
t.Errorf("/_app/ direct: status=%d, want 404", rec4.Code)
}
// Folder availability rules: classifier should NOT be served at root
// (root has no Incoming/Working/Staging ancestor), but SHOULD work in
// /Project-A/Working/.
rec5 := httptest.NewRecorder()
dispatch(cfg, idx, ring, appsSrv, rec5, httptest.NewRequest(http.MethodGet, "/classifier.html", nil))
if rec5.Code != http.StatusNotFound {
t.Errorf("/classifier.html at root: status=%d, want 404 (not in Incoming/Working/Staging)", rec5.Code)
}
rec6 := httptest.NewRecorder()
dispatch(cfg, idx, ring, appsSrv, rec6, httptest.NewRequest(http.MethodGet, "/Project-A/Working/classifier.html", nil))
if rec6.Code != http.StatusOK {
t.Errorf("/Project-A/Working/classifier.html: status=%d, want 200", rec6.Code)
}
}
// silence "imported and not used" if apps not referenced elsewhere — keep
// import even when we trim test cases later.
var _ = apps.DefaultUpstream
func mustMkdir(t *testing.T, path string) { func mustMkdir(t *testing.T, path string) {
t.Helper() t.Helper()
if err := os.MkdirAll(path, 0o755); err != nil { if err := os.MkdirAll(path, 0o755); err != nil {

401
zddc/internal/apps/apps.go Normal file
View file

@ -0,0 +1,401 @@
// Package apps serves the five ZDDC tool HTML files (archive, transmittal,
// classifier, mdedit, landing) on virtual paths in the file tree. Each tool
// is "available" only at directories whose name matches a folder convention
// (Incoming/Working/Staging) — see availability.go.
//
// Resolution priority for an enabled <dir>/<app>.html request:
//
// 1. Real file at the request path → static handler (operator override).
// 2. Subdir cascade — walk .zddc files root→leaf, accumulating URL prefix
// and channel/version components from the special `apps.default` key
// and the per-app `apps.<name>` key. Either component can be set,
// overridden, or left to inherit at any level. Path or full-`.html`-URL
// entries are *terminal* — they short-circuit composition and a deeper
// non-terminal entry overrides a parent terminal.
// 3. Embedded fallback — bytes baked into the binary at compile time via
// //go:embed. Used when no `apps:` entry was found anywhere up the chain.
//
// Spec forms (each is a string value in `.zddc apps:`):
//
// :stable / :beta / :alpha / :v0.0.4 / :v0.0 / :v0 — channel-only
// stable / beta / alpha / v0.0.4 / v0.0 / v0 — channel-only (no leading colon)
// https://host/path — URL-prefix only (combines with cascade channel)
// https://host/path:stable — URL-prefix + channel (composes)
// https://host/path/file.html — terminal full URL (used as-is)
// ./local.html / /abs/local.html — terminal local path
//
// No background refresh, no SHA-256 verification. To pick up new upstream
// bytes, delete the cache file (or the whole _app/ tree).
package apps
import (
"fmt"
"net/url"
"path/filepath"
"strings"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)
// DefaultUpstream is where channel and version shorthand specs resolve when
// no `apps.default` URL prefix is configured anywhere up the chain.
const DefaultUpstream = "https://zddc.varasys.io"
// DefaultUpstreamReleases is the prefix appended to DefaultUpstream when
// composing the canonical upstream URL.
const DefaultUpstreamReleases = DefaultUpstream + "/releases"
// DefaultChannel is the channel shorthand used when nothing in the chain
// specifies one.
const DefaultChannel = "stable"
// CacheDirName is the directory under ZDDC_ROOT where fetched URL sources
// are cached. The leading underscore excludes it from project listings;
// dispatch additionally blocks direct URL access.
const CacheDirName = "_app"
// DefaultAppsKey is the special key in `apps:` that provides the baseline
// URL prefix and channel for any app not overridden per-name. Cascades
// through .zddc files like everything else.
const DefaultAppsKey = "default"
// Source is a fully-resolved app source (output of Resolve).
type Source struct {
App string // canonical app name
URL string // upstream URL (mutually exclusive with Path)
Path string // resolved local file path
}
// IsURL reports whether the source is fetched (vs read from disk).
func (s Source) IsURL() bool { return s.URL != "" }
// SpecComponents is the parsed shape of a single `.zddc apps:` value.
// Terminal forms (Path or FullURL) are mutually exclusive with the
// composable URLPrefix/Channel forms. Resolve() turns one or more
// SpecComponents (one per applicable level in the cascade) into a final
// Source.
type SpecComponents struct {
// Terminal forms — exactly one set means the spec is terminal and
// short-circuits composition.
Path string // local file path (resolved + bounded to ZDDC_ROOT)
FullURL string // full URL ending in `.html` (used as-is)
// Composable forms — either or both may be set, both may be empty
// (caller should treat empty-everything as a no-op).
URLPrefix string // "https://host/path" (no trailing /)
Channel string // "stable" / "beta" / "alpha" / "v0.0.4" / "v0.0" / "v0"
}
// IsTerminal reports whether this spec terminates composition.
func (c SpecComponents) IsTerminal() bool {
return c.Path != "" || c.FullURL != ""
}
// IsEmpty reports whether the spec contributes nothing to composition.
func (c SpecComponents) IsEmpty() bool {
return c.Path == "" && c.FullURL == "" && c.URLPrefix == "" && c.Channel == ""
}
// ParseSpec parses one `.zddc apps:` value into components.
// zddcDir anchors relative paths; root bounds path-traversal.
func ParseSpec(spec, zddcDir, root string) (SpecComponents, error) {
spec = strings.TrimSpace(spec)
if spec == "" {
return SpecComponents{}, fmt.Errorf("source spec is empty")
}
// Path forms — terminal.
if strings.HasPrefix(spec, "/") ||
strings.HasPrefix(spec, "./") ||
strings.HasPrefix(spec, "../") {
var abs string
if filepath.IsAbs(spec) {
abs = filepath.Clean(spec)
} else {
abs = filepath.Clean(filepath.Join(zddcDir, spec))
}
rootClean := filepath.Clean(root)
if abs != rootClean && !strings.HasPrefix(abs, rootClean+string(filepath.Separator)) {
return SpecComponents{}, fmt.Errorf("path %q escapes ZDDC_ROOT", spec)
}
return SpecComponents{Path: abs}, nil
}
// URL forms.
if strings.HasPrefix(spec, "https://") || strings.HasPrefix(spec, "http://") {
return parseURLSpec(spec)
}
// Channel-only forms: ":channel" or bare "channel".
chanPart := strings.TrimPrefix(spec, ":")
if chanPart == "" {
return SpecComponents{}, fmt.Errorf("empty channel after ':'")
}
if !isValidChannelOrVersion(chanPart) {
return SpecComponents{}, fmt.Errorf("unrecognized source spec %q (expected channel, version, URL, or path)", spec)
}
return SpecComponents{Channel: normalizeChannel(chanPart)}, nil
}
// parseURLSpec splits a URL spec into prefix vs full-URL based on the
// last `:` after the last `/`. Examples:
//
// https://host:8080/path:stable → URLPrefix=https://host:8080/path, Channel=stable
// https://host:8080/path → URLPrefix=https://host:8080/path
// https://host/path/file.html → FullURL=https://host/path/file.html (terminal)
// https://host/path/file.html:beta → error (terminal URL with extra suffix)
func parseURLSpec(spec string) (SpecComponents, error) {
// Locate the channel separator: last `:` that comes after the last `/`.
lastSlash := strings.LastIndex(spec, "/")
if lastSlash < 0 {
return SpecComponents{}, 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:]
}
// Validate the URL portion.
u, err := url.Parse(urlPart)
if err != nil {
return SpecComponents{}, fmt.Errorf("invalid URL %q: %w", urlPart, err)
}
if u.Host == "" {
return SpecComponents{}, fmt.Errorf("URL %q is missing host", urlPart)
}
// Terminal full URL: ends in `.html`. A `:suffix` on a `.html` URL is
// rejected to prevent silent misinterpretation.
if strings.HasSuffix(urlPart, ".html") {
if suffixPart != "" {
return SpecComponents{}, fmt.Errorf("URL ends in .html but has %q suffix", ":"+suffixPart)
}
return SpecComponents{FullURL: urlPart}, nil
}
// URL-prefix form. Strip trailing slash for normalization.
prefix := strings.TrimRight(urlPart, "/")
out := SpecComponents{URLPrefix: prefix}
if suffixPart != "" {
if !isValidChannelOrVersion(suffixPart) {
return SpecComponents{}, fmt.Errorf("invalid channel/version suffix %q", suffixPart)
}
out.Channel = normalizeChannel(suffixPart)
}
return out, nil
}
// isValidChannelOrVersion reports whether s is `stable`/`beta`/`alpha` or a
// version like `v0.0.4`/`0.0.4`/`v0.0`/`v0`.
func isValidChannelOrVersion(s string) bool {
if s == "stable" || s == "beta" || s == "alpha" {
return true
}
rest := strings.TrimPrefix(s, "v")
if rest == "" {
return false
}
parts := strings.Split(rest, ".")
if len(parts) > 3 {
return false
}
for _, p := range parts {
if p == "" {
return false
}
for _, r := range p {
if r < '0' || r > '9' {
return false
}
}
}
return true
}
// normalizeChannel ensures versions carry the `v` prefix (so the resulting
// filename is `<app>_v<X.Y.Z>.html` per upstream convention).
func normalizeChannel(s string) string {
if s == "stable" || s == "beta" || s == "alpha" {
return s
}
if !strings.HasPrefix(s, "v") {
return "v" + s
}
return s
}
// Resolve walks the .zddc chain root→leaf, applying `apps.default` and
// `apps.<app>` at each level. Returns the resolved Source and true if any
// entry contributed; (Source{}, false, nil) means no override (caller
// serves embedded). On malformed spec, returns an error.
func Resolve(chain zddc.PolicyChain, app, root, requestDir string) (Source, bool, error) {
return ResolveWithOverride(chain, app, root, requestDir, "")
}
// ResolveWithOverride is Resolve with an additional per-request override
// applied as one final cascade level after the .zddc chain. Used to honor
// the `?v=` query parameter on tool HTML requests.
//
// vSpec accepts the same syntax as `.zddc apps:` values (channel/version,
// `:channel`, URL prefix, `url:channel`, full `.html` URL). Path sources
// are rejected (security: `?v=` must resolve to a URL whose bytes the
// caller can fetch from cache only).
//
// Empty vSpec is equivalent to plain Resolve.
func ResolveWithOverride(chain zddc.PolicyChain, app, root, requestDir, vSpec string) (Source, bool, error) {
app = strings.ToLower(strings.TrimSpace(app))
if !zddc.IsKnownApp(app) {
return Source{}, false, fmt.Errorf("unknown app %q", app)
}
dirs := walkDirs(root, requestDir)
st := newAppsState(app)
// Walk root → leaf.
for i := 0; i < len(chain.Levels); i++ {
lvl := chain.Levels[i]
dir := root
if i < len(dirs) {
dir = dirs[i]
}
// `default` first, then per-app override at the same level.
if spec, ok := lvl.Apps[DefaultAppsKey]; ok && spec != "" {
if err := st.apply(spec, dir, root, "apps.default"); err != nil {
return Source{}, false, err
}
}
if spec, ok := lvl.Apps[app]; ok && spec != "" {
if err := st.apply(spec, dir, root, "apps."+app); err != nil {
return Source{}, false, err
}
}
}
// Per-request override (`?v=`): one final layer.
if vSpec = strings.TrimSpace(vSpec); vSpec != "" {
comp, err := ParseSpec(vSpec, requestDir, root)
if err != nil {
return Source{}, false, fmt.Errorf("?v=%s: %w", vSpec, err)
}
// Reject path sources from per-request override — security: we serve
// only what the cache (populated by .zddc-controlled fetches) holds.
if comp.Path != "" {
return Source{}, false, fmt.Errorf("?v= cannot specify a local path source")
}
if err := st.applyComponents(comp); err != nil {
return Source{}, false, fmt.Errorf("?v=%s: %w", vSpec, err)
}
}
return st.finalize()
}
// appsState accumulates URL-prefix and channel components across cascade
// levels, with terminal-source short-circuit semantics.
type appsState struct {
app string
haveAny bool
urlPrefix string
channel string
terminalSrc *Source
}
func newAppsState(app string) *appsState {
return &appsState{app: app}
}
func (s *appsState) apply(spec, zddcDir, root, label string) error {
comp, err := ParseSpec(spec, zddcDir, root)
if err != nil {
return fmt.Errorf("%s: %w", label, err)
}
return s.applyComponents(comp)
}
func (s *appsState) applyComponents(comp SpecComponents) error {
if comp.IsEmpty() {
return nil
}
s.haveAny = true
switch {
case comp.Path != "":
s.terminalSrc = &Source{App: s.app, Path: comp.Path}
s.urlPrefix, s.channel = "", ""
case comp.FullURL != "":
s.terminalSrc = &Source{App: s.app, URL: comp.FullURL}
s.urlPrefix, s.channel = "", ""
default:
// Non-terminal: deeper non-terminal entries override a parent terminal.
s.terminalSrc = nil
if comp.URLPrefix != "" {
s.urlPrefix = comp.URLPrefix
}
if comp.Channel != "" {
s.channel = comp.Channel
}
}
return nil
}
func (s *appsState) finalize() (Source, bool, error) {
if !s.haveAny {
return Source{}, false, nil
}
if s.terminalSrc != nil {
return *s.terminalSrc, true, nil
}
urlPrefix := s.urlPrefix
if urlPrefix == "" {
urlPrefix = DefaultUpstreamReleases
}
channel := s.channel
if channel == "" {
channel = DefaultChannel
}
return Source{
App: s.app,
URL: urlPrefix + "/" + s.app + "_" + channel + ".html",
}, true, nil
}
// PreviewLine returns a short human-readable description of how an app
// resolves at requestDir given the chain. Used by the .zddc editor to
// render a "Resolves to: ..." line beside each apps input.
func PreviewLine(chain zddc.PolicyChain, app, root, requestDir string) string {
src, has, err := Resolve(chain, app, root, requestDir)
if err != nil {
return "error: " + err.Error()
}
if !has {
return "embedded (build-time default)"
}
if src.Path != "" {
return "local file: " + src.Path
}
return src.URL
}
func walkDirs(root, requestDir string) []string {
root = filepath.Clean(root)
requestDir = filepath.Clean(requestDir)
if requestDir == root {
return []string{root}
}
rel, err := filepath.Rel(root, requestDir)
if err != nil {
return []string{root}
}
dirs := []string{root}
cur := root
for _, part := range strings.Split(rel, string(filepath.Separator)) {
cur = filepath.Join(cur, part)
dirs = append(dirs, cur)
}
return dirs
}

View file

@ -0,0 +1,426 @@
package apps
import (
"path/filepath"
"strings"
"testing"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)
// ── ParseSpec ────────────────────────────────────────────────────────────
func TestParseSpec_Channels(t *testing.T) {
cases := []struct {
spec, wantChan string
}{
{"stable", "stable"},
{"beta", "beta"},
{"alpha", "alpha"},
{":stable", "stable"},
{":beta", "beta"},
{":alpha", "alpha"},
}
for _, tc := range cases {
t.Run(tc.spec, func(t *testing.T) {
c, err := ParseSpec(tc.spec, "/root", "/root")
if err != nil {
t.Fatalf("ParseSpec error: %v", err)
}
if c.Channel != tc.wantChan {
t.Errorf("got Channel=%q, want %q", c.Channel, tc.wantChan)
}
if c.URLPrefix != "" || c.Path != "" || c.FullURL != "" {
t.Errorf("expected channel-only, got %+v", c)
}
})
}
}
func TestParseSpec_Versions(t *testing.T) {
cases := []struct {
spec, wantChan string
}{
{"v0.0.4", "v0.0.4"},
{"0.0.4", "v0.0.4"},
{"v0.0", "v0.0"},
{"0.0", "v0.0"},
{"v0", "v0"},
{"0", "v0"},
{":v0.0.4", "v0.0.4"},
{":0.0.4", "v0.0.4"},
{":v0", "v0"},
}
for _, tc := range cases {
t.Run(tc.spec, func(t *testing.T) {
c, err := ParseSpec(tc.spec, "/root", "/root")
if err != nil {
t.Fatalf("ParseSpec error: %v", err)
}
if c.Channel != tc.wantChan {
t.Errorf("got Channel=%q, want %q", c.Channel, tc.wantChan)
}
})
}
}
func TestParseSpec_URLPrefix(t *testing.T) {
cases := []struct {
spec, wantPrefix, wantChan string
}{
{"https://my-mirror.example/releases", "https://my-mirror.example/releases", ""},
{"https://my-mirror.example/releases/", "https://my-mirror.example/releases", ""}, // trailing slash stripped
{"https://my-mirror.example/releases:stable", "https://my-mirror.example/releases", "stable"},
{"https://my-mirror.example/releases:beta", "https://my-mirror.example/releases", "beta"},
{"https://my-mirror.example/releases:v0.0.4", "https://my-mirror.example/releases", "v0.0.4"},
// Port colon must NOT be confused with channel separator.
{"https://my-mirror.example:8080/releases", "https://my-mirror.example:8080/releases", ""},
{"https://my-mirror.example:8080/releases:stable", "https://my-mirror.example:8080/releases", "stable"},
// Colon embedded in path before final slash — treated as part of path.
{"https://host/some:thing/releases", "https://host/some:thing/releases", ""},
{"https://host/some:thing/releases:beta", "https://host/some:thing/releases", "beta"},
}
for _, tc := range cases {
t.Run(tc.spec, func(t *testing.T) {
c, err := ParseSpec(tc.spec, "/root", "/root")
if err != nil {
t.Fatalf("ParseSpec error: %v", err)
}
if c.URLPrefix != tc.wantPrefix {
t.Errorf("got URLPrefix=%q, want %q", c.URLPrefix, tc.wantPrefix)
}
if c.Channel != tc.wantChan {
t.Errorf("got Channel=%q, want %q", c.Channel, tc.wantChan)
}
if c.Path != "" || c.FullURL != "" {
t.Errorf("expected non-terminal, got %+v", c)
}
})
}
}
func TestParseSpec_FullURL(t *testing.T) {
c, err := ParseSpec("https://my-fork.example/archive.html", "/root", "/root")
if err != nil {
t.Fatalf("ParseSpec error: %v", err)
}
if c.FullURL != "https://my-fork.example/archive.html" {
t.Errorf("got FullURL=%q", c.FullURL)
}
if !c.IsTerminal() {
t.Errorf("expected terminal")
}
}
func TestParseSpec_FullURLWithChannelSuffixRejected(t *testing.T) {
_, err := ParseSpec("https://my-fork.example/archive.html:stable", "/root", "/root")
if err == nil {
t.Errorf("expected error for .html URL with :suffix")
}
}
func TestParseSpec_Paths(t *testing.T) {
root := t.TempDir()
zddcDir := filepath.Join(root, "Project-X")
cases := []struct {
spec string
wantOK bool
wantPath string
}{
{"./local.html", true, filepath.Join(zddcDir, "local.html")},
{"../sibling.html", true, filepath.Join(root, "sibling.html")},
{filepath.Join(root, "abs.html"), true, filepath.Join(root, "abs.html")},
{"/etc/passwd", false, ""},
{"../../../escape.html", false, ""},
}
for _, tc := range cases {
t.Run(tc.spec, func(t *testing.T) {
c, err := ParseSpec(tc.spec, zddcDir, root)
if tc.wantOK {
if err != nil {
t.Fatalf("want success, got error: %v", err)
}
if c.Path != tc.wantPath {
t.Errorf("got Path=%q, want %q", c.Path, tc.wantPath)
}
if !c.IsTerminal() {
t.Errorf("expected terminal")
}
} else {
if err == nil {
t.Errorf("want error, got %+v", c)
}
}
})
}
}
func TestParseSpec_Errors(t *testing.T) {
cases := []string{
"",
"weird-thing",
":",
":weird",
"v",
"v0.0.0.0",
"v0.a.0",
"https://", // missing host
}
for _, tc := range cases {
t.Run(tc, func(t *testing.T) {
_, err := ParseSpec(tc, "/root", "/root")
if err == nil {
t.Errorf("ParseSpec(%q) = nil, want error", tc)
}
})
}
}
// ── Resolve ──────────────────────────────────────────────────────────────
func TestResolve_NoEntries(t *testing.T) {
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{}}}
_, has, err := Resolve(chain, "archive", t.TempDir(), t.TempDir())
if err != nil {
t.Fatalf("Resolve: %v", err)
}
if has {
t.Errorf("got override=true, want false")
}
}
func TestResolve_PerAppChannelOnly(t *testing.T) {
root := t.TempDir()
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{
Apps: map[string]string{"archive": "beta"},
}}}
src, has, err := Resolve(chain, "archive", root, root)
if err != nil || !has {
t.Fatalf("has=%v err=%v", has, err)
}
want := DefaultUpstreamReleases + "/archive_beta.html"
if src.URL != want {
t.Errorf("got URL=%q, want %q", src.URL, want)
}
}
func TestResolve_PerAppVersionOnly(t *testing.T) {
root := t.TempDir()
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{
Apps: map[string]string{"archive": "v0.0.4"},
}}}
src, _, err := Resolve(chain, "archive", root, root)
if err != nil {
t.Fatal(err)
}
want := DefaultUpstreamReleases + "/archive_v0.0.4.html"
if src.URL != want {
t.Errorf("got URL=%q, want %q", src.URL, want)
}
}
func TestResolve_DefaultProvidesURLAndChannel(t *testing.T) {
root := t.TempDir()
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{
Apps: map[string]string{
"default": "https://mirror.example/releases:beta",
},
}}}
src, has, err := Resolve(chain, "archive", root, root)
if err != nil || !has {
t.Fatalf("has=%v err=%v", has, err)
}
if src.URL != "https://mirror.example/releases/archive_beta.html" {
t.Errorf("got URL=%q", src.URL)
}
}
func TestResolve_DefaultPlusPerAppChannelOverride(t *testing.T) {
// User's example: default=https://zddc.varasys.io/releases:stable,
// classifier=:beta → mirror URL with classifier_beta.html.
root := t.TempDir()
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{
Apps: map[string]string{
"default": "https://zddc.varasys.io/releases:stable",
"classifier": ":beta",
},
}}}
src, _, err := Resolve(chain, "classifier", root, root)
if err != nil {
t.Fatal(err)
}
if src.URL != "https://zddc.varasys.io/releases/classifier_beta.html" {
t.Errorf("got URL=%q", src.URL)
}
}
func TestResolve_DefaultPlusPerAppURLPrefixOverride(t *testing.T) {
// User's example: default=...:stable, archive=https://my.local.stuff/releases
// → custom URL + default channel (stable).
root := t.TempDir()
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{
Apps: map[string]string{
"default": "https://zddc.varasys.io/releases:stable",
"archive": "https://my.local.stuff/releases",
},
}}}
src, _, err := Resolve(chain, "archive", root, root)
if err != nil {
t.Fatal(err)
}
if src.URL != "https://my.local.stuff/releases/archive_stable.html" {
t.Errorf("got URL=%q", src.URL)
}
}
func TestResolve_DeeperLevelOverridesParentChannel(t *testing.T) {
root := t.TempDir()
requestDir := filepath.Join(root, "Project-A")
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{
{Apps: map[string]string{"default": ":stable"}},
{Apps: map[string]string{"default": ":beta"}},
}}
src, _, err := Resolve(chain, "archive", root, requestDir)
if err != nil {
t.Fatal(err)
}
want := DefaultUpstreamReleases + "/archive_beta.html"
if src.URL != want {
t.Errorf("got URL=%q, want %q", src.URL, want)
}
}
func TestResolve_DeeperLevelOverridesParentURL(t *testing.T) {
root := t.TempDir()
requestDir := filepath.Join(root, "Project-A")
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{
{Apps: map[string]string{"default": "https://a.example/releases:stable"}},
{Apps: map[string]string{"default": "https://b.example/releases"}},
}}
src, _, err := Resolve(chain, "archive", root, requestDir)
if err != nil {
t.Fatal(err)
}
// b.example URL prefix wins; channel inherited (stable).
want := "https://b.example/releases/archive_stable.html"
if src.URL != want {
t.Errorf("got URL=%q, want %q", src.URL, want)
}
}
func TestResolve_TerminalAtLeafBeatsParentDefault(t *testing.T) {
root := t.TempDir()
requestDir := filepath.Join(root, "Project-A")
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{
{Apps: map[string]string{"default": "https://a.example/releases:stable"}},
{Apps: map[string]string{"archive": "https://my-fork.example/archive.html"}},
}}
src, _, err := Resolve(chain, "archive", root, requestDir)
if err != nil {
t.Fatal(err)
}
if src.URL != "https://my-fork.example/archive.html" {
t.Errorf("got URL=%q (want terminal full URL)", src.URL)
}
}
func TestResolve_DeeperNonTerminalOverridesParentTerminal(t *testing.T) {
root := t.TempDir()
requestDir := filepath.Join(root, "Project-A")
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{
{Apps: map[string]string{"archive": "https://a.example/archive.html"}}, // terminal
{Apps: map[string]string{"archive": "alpha"}}, // non-terminal
}}
src, _, err := Resolve(chain, "archive", root, requestDir)
if err != nil {
t.Fatal(err)
}
want := DefaultUpstreamReleases + "/archive_alpha.html"
if src.URL != want {
t.Errorf("got URL=%q, want %q", src.URL, want)
}
}
func TestResolve_PathSourceTerminal(t *testing.T) {
root := t.TempDir()
projDir := filepath.Join(root, "Project-X")
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{
{},
{Apps: map[string]string{"archive": "./our-archive.html"}},
}}
src, _, err := Resolve(chain, "archive", root, projDir)
if err != nil {
t.Fatal(err)
}
if src.URL != "" {
t.Errorf("got URL=%q, want empty", src.URL)
}
want := filepath.Join(projDir, "our-archive.html")
if src.Path != want {
t.Errorf("got Path=%q, want %q", src.Path, want)
}
}
func TestResolve_PerAppOverridesDefaultAtSameLevel(t *testing.T) {
root := t.TempDir()
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{
Apps: map[string]string{
"default": "https://a.example/releases:stable",
"archive": "https://b.example/archive.html", // terminal — wins for archive only
},
}}}
src, _, err := Resolve(chain, "archive", root, root)
if err != nil {
t.Fatal(err)
}
if src.URL != "https://b.example/archive.html" {
t.Errorf("got URL=%q (want b.example terminal)", src.URL)
}
// Other apps still use the default.
src2, _, err := Resolve(chain, "classifier", root, root)
if err != nil {
t.Fatal(err)
}
if src2.URL != "https://a.example/releases/classifier_stable.html" {
t.Errorf("got classifier URL=%q (want a.example default)", src2.URL)
}
}
func TestResolve_BadSpecBubblesError(t *testing.T) {
root := t.TempDir()
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{
Apps: map[string]string{"archive": "this is garbage"},
}}}
_, _, err := Resolve(chain, "archive", root, root)
if err == nil {
t.Errorf("expected error")
}
}
func TestResolve_UnknownAppRejected(t *testing.T) {
root := t.TempDir()
_, _, err := Resolve(zddc.PolicyChain{}, "unknown", root, root)
if err == nil {
t.Errorf("expected error")
}
}
// ── PreviewLine ──────────────────────────────────────────────────────────
func TestPreviewLine(t *testing.T) {
root := t.TempDir()
t.Run("no entries → embedded", func(t *testing.T) {
got := PreviewLine(zddc.PolicyChain{Levels: []zddc.ZddcFile{{}}}, "archive", root, root)
if !strings.Contains(got, "embedded") {
t.Errorf("got %q", got)
}
})
t.Run("default channel → URL", func(t *testing.T) {
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{Apps: map[string]string{"default": ":beta"}}}}
got := PreviewLine(chain, "archive", root, root)
if !strings.Contains(got, "archive_beta.html") {
t.Errorf("got %q", got)
}
})
}

View file

@ -0,0 +1,71 @@
package apps
import (
"path/filepath"
"strings"
)
// Folder name conventions that gate which tools are virtually available
// at a given path. The names are case-sensitive; ZDDC convention uses
// the capitalized forms.
var (
folderNamesIncomingWorkingStaging = []string{"Incoming", "Working", "Staging"}
folderNamesWorking = []string{"Working"}
folderNamesStaging = []string{"Staging"}
)
// AppAvailableAt reports whether app's virtual HTML can be served at
// requestDir. Rules:
//
// - archive: every directory (multi-project, project, archive, vendor)
// - classifier: requestDir is, or descends from, a folder named
// "Incoming", "Working", or "Staging" (the directories where
// incoming/outgoing files get classified)
// - mdedit: requestDir is, or descends from, a "Working" folder
// (where markdown drafts are written and edited)
// - transmittal: requestDir is, or descends from, a "Staging" folder
// (where outgoing transmittals are prepared)
// - landing: only at the deployment root (the project picker)
//
// Operators can always drop a real <name>.html file at any path to override
// — that path is served by the static handler regardless of this function's
// result. AppAvailableAt is consulted only when no real file exists.
func AppAvailableAt(root, requestDir, app string) bool {
root = filepath.Clean(root)
requestDir = filepath.Clean(requestDir)
switch app {
case "archive":
return true
case "landing":
return requestDir == root
case "classifier":
return inAncestorWithName(root, requestDir, folderNamesIncomingWorkingStaging)
case "mdedit":
return inAncestorWithName(root, requestDir, folderNamesWorking)
case "transmittal":
return inAncestorWithName(root, requestDir, folderNamesStaging)
}
return false
}
// inAncestorWithName reports whether requestDir is, or has an ancestor
// (not including root itself), named one of names. The match is on the
// last segment of each directory in the chain root → requestDir.
func inAncestorWithName(root, requestDir string, names []string) bool {
if requestDir == root {
return false
}
rel, err := filepath.Rel(root, requestDir)
if err != nil || strings.HasPrefix(rel, "..") {
return false
}
for _, part := range strings.Split(rel, string(filepath.Separator)) {
for _, n := range names {
if part == n {
return true
}
}
}
return false
}

View file

@ -0,0 +1,61 @@
package apps
import (
"path/filepath"
"testing"
)
func TestAppAvailableAt(t *testing.T) {
root := "/srv/zddc"
cases := []struct {
dir, app string
want bool
}{
// archive: everywhere
{root, "archive", true},
{root + "/Project-A", "archive", true},
{root + "/Project-A/Working", "archive", true},
{root + "/Project-A/Outgoing", "archive", true},
// landing: only at root
{root, "landing", true},
{root + "/Project-A", "landing", false},
// classifier: Incoming/Working/Staging and subtrees
{root, "classifier", false},
{root + "/Project-A", "classifier", false},
{root + "/Project-A/Incoming", "classifier", true},
{root + "/Project-A/Incoming/SubDir", "classifier", true},
{root + "/Project-A/Working", "classifier", true},
{root + "/Project-A/Staging", "classifier", true},
{root + "/Project-A/Outgoing", "classifier", false},
{root + "/Project-A/Working/deep/nested/path", "classifier", true},
// mdedit: Working only
{root + "/Project-A/Working", "mdedit", true},
{root + "/Project-A/Working/SubDir", "mdedit", true},
{root + "/Project-A/Incoming", "mdedit", false},
{root + "/Project-A/Staging", "mdedit", false},
// transmittal: Staging only
{root + "/Project-A/Staging", "transmittal", true},
{root + "/Project-A/Staging/SubDir", "transmittal", true},
{root + "/Project-A/Incoming", "transmittal", false},
{root + "/Project-A/Working", "transmittal", false},
// case-sensitivity: lowercase doesn't match
{root + "/Project-A/working", "mdedit", false},
{root + "/Project-A/staging", "transmittal", false},
// unknown app
{root + "/Project-A", "weird", false},
}
for _, tc := range cases {
t.Run(tc.app+"@"+tc.dir, func(t *testing.T) {
got := AppAvailableAt(root, filepath.Clean(tc.dir), tc.app)
if got != tc.want {
t.Errorf("AppAvailableAt(%q, %q) = %v, want %v", tc.dir, tc.app, got, tc.want)
}
})
}
}

165
zddc/internal/apps/cache.go Normal file
View file

@ -0,0 +1,165 @@
package apps
import (
"fmt"
"io/fs"
"net/url"
"os"
"path/filepath"
"strings"
)
// Cache stores fetched URL responses on disk under <ZDDC_ROOT>/_app/.
// Files are name-keyed by upstream host + path so operators can list
// and inspect them by hand. There is no metadata, no SHA-256, no
// expiration — fetch-once-and-keep-forever. To force a refetch,
// delete the cache file.
type Cache struct {
root string
}
// NewCache creates a Cache rooted at the given path. The directory is
// created if missing. Stale *.tmp files left over from interrupted
// writes are swept on construction.
func NewCache(root string) (*Cache, error) {
root = filepath.Clean(root)
if err := os.MkdirAll(root, 0o755); err != nil {
return nil, fmt.Errorf("create cache root: %w", err)
}
c := &Cache{root: root}
if err := c.sweepTemps(); err != nil {
return nil, fmt.Errorf("sweep temps: %w", err)
}
return c, nil
}
// Root returns the cache directory absolute path.
func (c *Cache) Root() string { return c.root }
// keyForURL converts a URL into a relative filesystem path under the
// cache root, e.g. "zddc.varasys.io/releases/archive_stable.html".
func keyForURL(rawURL string) (string, error) {
u, err := url.Parse(rawURL)
if err != nil {
return "", fmt.Errorf("parse URL: %w", err)
}
if u.Scheme != "http" && u.Scheme != "https" {
return "", fmt.Errorf("unsupported scheme %q", u.Scheme)
}
if u.Host == "" {
return "", fmt.Errorf("URL is missing host")
}
if u.RawQuery != "" {
return "", fmt.Errorf("URL must not contain query string: %s", rawURL)
}
host := strings.ToLower(u.Host)
if i := strings.Index(host, ":"); i >= 0 {
port := host[i+1:]
hostOnly := host[:i]
if (u.Scheme == "http" && port == "80") || (u.Scheme == "https" && port == "443") {
host = hostOnly
}
}
p := u.Path
for strings.Contains(p, "//") {
p = strings.ReplaceAll(p, "//", "/")
}
p = strings.TrimPrefix(p, "/")
if p == "" {
p = "index.html"
}
cleaned := filepath.Clean("/" + p)
if strings.Contains(cleaned, "..") {
return "", fmt.Errorf("URL path contains '..'")
}
return host + cleaned, nil
}
func (c *Cache) pathFor(rawURL string) (string, error) {
key, err := keyForURL(rawURL)
if err != nil {
return "", err
}
return filepath.Join(c.root, filepath.FromSlash(key)), nil
}
// Has reports whether a cache entry exists for the URL.
func (c *Cache) Has(rawURL string) bool {
p, err := c.pathFor(rawURL)
if err != nil {
return false
}
_, err = os.Stat(p)
return err == nil
}
// Read returns the cached body or os.ErrNotExist.
func (c *Cache) Read(rawURL string) ([]byte, error) {
p, err := c.pathFor(rawURL)
if err != nil {
return nil, err
}
return os.ReadFile(p)
}
// Write atomically stores body for the URL. Parent directories are
// created as needed. Writes via tmp+rename so partial files are never
// observable.
func (c *Cache) Write(rawURL string, body []byte) error {
p, err := c.pathFor(rawURL)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
return err
}
return writeAtomic(p, body)
}
func writeAtomic(path string, data []byte) error {
dir := filepath.Dir(path)
tmp, err := os.CreateTemp(dir, filepath.Base(path)+".tmp.*")
if err != nil {
return err
}
tmpName := tmp.Name()
cleanup := func() { _ = os.Remove(tmpName) }
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
cleanup()
return err
}
if err := tmp.Sync(); err != nil {
_ = tmp.Close()
cleanup()
return err
}
if err := tmp.Close(); err != nil {
cleanup()
return err
}
if err := os.Rename(tmpName, path); err != nil {
cleanup()
return err
}
return nil
}
func (c *Cache) sweepTemps() error {
err := filepath.WalkDir(c.root, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
if strings.Contains(d.Name(), ".tmp.") {
_ = os.Remove(p)
}
return nil
})
if err != nil && !os.IsNotExist(err) {
return err
}
return nil
}

View file

@ -0,0 +1,116 @@
package apps
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestKeyForURL(t *testing.T) {
cases := []struct {
raw, want string
}{
{"https://zddc.varasys.io/releases/archive_stable.html", "zddc.varasys.io/releases/archive_stable.html"},
{"https://ZDDC.Varasys.IO/releases/archive_stable.html", "zddc.varasys.io/releases/archive_stable.html"},
{"http://example.com:80/foo.html", "example.com/foo.html"},
{"https://example.com:443/foo.html", "example.com/foo.html"},
{"https://example.com:8443/foo.html", "example.com:8443/foo.html"},
{"https://example.com/", "example.com/index.html"},
{"https://example.com", "example.com/index.html"},
{"https://example.com//foo//bar.html", "example.com/foo/bar.html"},
}
for _, tc := range cases {
t.Run(tc.raw, func(t *testing.T) {
got, err := keyForURL(tc.raw)
if err != nil {
t.Fatalf("keyForURL error: %v", err)
}
if got != tc.want {
t.Errorf("got %q, want %q", got, tc.want)
}
})
}
}
func TestKeyForURL_Errors(t *testing.T) {
cases := []string{
"",
"not-a-url",
"ftp://example.com/x.html",
"https:///x.html",
"https://example.com/x.html?v=1",
}
for _, tc := range cases {
t.Run(tc, func(t *testing.T) {
if _, err := keyForURL(tc); err == nil {
t.Errorf("keyForURL(%q) = nil, want error", tc)
}
})
}
}
func TestCacheRoundtrip(t *testing.T) {
c, err := NewCache(filepath.Join(t.TempDir(), "_app"))
if err != nil {
t.Fatalf("NewCache: %v", err)
}
urlStr := "https://zddc.varasys.io/releases/archive_stable.html"
body := []byte("<!DOCTYPE html>archive content")
if c.Has(urlStr) {
t.Fatalf("Has(empty cache) = true, want false")
}
if err := c.Write(urlStr, body); err != nil {
t.Fatalf("Write: %v", err)
}
if !c.Has(urlStr) {
t.Fatalf("Has(after write) = false, want true")
}
got, err := c.Read(urlStr)
if err != nil {
t.Fatalf("Read: %v", err)
}
if string(got) != string(body) {
t.Errorf("body mismatch")
}
}
func TestCacheAtomicWrite_LeavesNoTempOnSuccess(t *testing.T) {
root := filepath.Join(t.TempDir(), "_app")
c, _ := NewCache(root)
urlStr := "https://zddc.varasys.io/releases/archive_stable.html"
if err := c.Write(urlStr, []byte("hello")); err != nil {
t.Fatalf("Write: %v", err)
}
count := 0
_ = filepath.Walk(root, func(p string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() && strings.Contains(info.Name(), ".tmp.") {
count++
}
return nil
})
if count != 0 {
t.Errorf("found %d .tmp.* leftovers, want 0", count)
}
}
func TestCacheSweepsTempsOnNew(t *testing.T) {
root := filepath.Join(t.TempDir(), "_app")
stale := filepath.Join(root, "example.com", "releases", "archive_stable.html.tmp.123")
if err := os.MkdirAll(filepath.Dir(stale), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(stale, []byte("partial"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := NewCache(root); err != nil {
t.Fatalf("NewCache: %v", err)
}
if _, err := os.Stat(stale); !os.IsNotExist(err) {
t.Errorf("stale tmp file not swept: %v", err)
}
}

View file

@ -0,0 +1,53 @@
package apps
import _ "embed"
// Embedded fallback: the five tool HTMLs from the time the binary was
// built. Used as a last-resort served-bytes when (cache miss) AND
// (upstream unreachable) AND (no operator override) — see handler.go.
//
// The files are populated by the top-level build.sh, which copies the
// freshly-built dist/<tool>.html into ./embedded/ before `go build` runs.
// Empty placeholder files are checked in so the package compiles when no
// build has been run yet (CI bootstrap, fresh clone, etc.); at runtime an
// empty embedded body is treated as "no embedded fallback available."
//go:embed embedded/archive.html
var embeddedArchive []byte
//go:embed embedded/transmittal.html
var embeddedTransmittal []byte
//go:embed embedded/classifier.html
var embeddedClassifier []byte
//go:embed embedded/mdedit.html
var embeddedMdedit []byte
//go:embed embedded/index.html
var embeddedLanding []byte
// EmbeddedBytes returns the embedded HTML for app, or nil if either app is
// not one of the canonical names or the embedded slot is empty (no build
// has populated it).
func EmbeddedBytes(app string) []byte {
var b []byte
switch app {
case "archive":
b = embeddedArchive
case "transmittal":
b = embeddedTransmittal
case "classifier":
b = embeddedClassifier
case "mdedit":
b = embeddedMdedit
case "landing":
b = embeddedLanding
default:
return nil
}
if len(b) == 0 {
return nil
}
return b
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

102
zddc/internal/apps/fetch.go Normal file
View file

@ -0,0 +1,102 @@
package apps
import (
"context"
"fmt"
"io"
"log/slog"
"net/http"
"sync"
"time"
)
// Fetcher pulls URL sources once, caches the body forever, and serves
// from cache on subsequent calls. Path sources don't go through here —
// the handler reads the file directly.
//
// Concurrent calls for the same URL dedupe via singleflight. There is no
// background refresh, no conditional GET, no SHA-256 verification.
type Fetcher struct {
Cache *Cache
Client *http.Client
Logger *slog.Logger
sf singleflightGroup
embeddedFails sync.Map // url → struct{} (rate-limit "fell back to embedded" warnings)
}
// NewFetcher returns a Fetcher with sensible defaults: 10s timeout, no
// redirects (ops must point at the final URL).
func NewFetcher(cache *Cache, logger *slog.Logger) *Fetcher {
if logger == nil {
logger = slog.Default()
}
return &Fetcher{
Cache: cache,
Logger: logger,
Client: &http.Client{
Timeout: 10 * time.Second,
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
},
}
}
// Fetch returns the body for url. If the cache already has it, returns
// the cached bytes immediately. Otherwise fetches, caches, and returns.
// All concurrent requests for the same URL share one outbound fetch.
func (f *Fetcher) Fetch(ctx context.Context, urlStr string) ([]byte, error) {
if f.Cache != nil {
if body, err := f.Cache.Read(urlStr); err == nil {
return body, nil
}
}
val, err := f.sf.Do(urlStr, func() (any, error) {
return f.fetchOnce(ctx, urlStr)
})
if err != nil {
return nil, err
}
return val.([]byte), nil
}
func (f *Fetcher) fetchOnce(ctx context.Context, urlStr string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, urlStr, nil)
if err != nil {
return nil, err
}
resp, err := f.Client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("upstream %s returned HTTP %d", urlStr, resp.StatusCode)
}
const maxBytes = 25 * 1024 * 1024
body, err := io.ReadAll(io.LimitReader(resp.Body, maxBytes+1))
if err != nil {
return nil, err
}
if int64(len(body)) > maxBytes {
return nil, fmt.Errorf("response from %s exceeds %d bytes", urlStr, maxBytes)
}
if f.Cache != nil {
if err := f.Cache.Write(urlStr, body); err != nil {
f.Logger.Warn("cache write failed; serving from response anyway",
"url", urlStr, "err", err)
}
}
return body, nil
}
// LogEmbeddedFallback emits a one-time warning when the embedded fallback
// is used for a particular source URL. Rate-limited per URL.
func (f *Fetcher) LogEmbeddedFallback(app, urlStr string, reason error) {
if _, loaded := f.embeddedFails.LoadOrStore(urlStr, struct{}{}); loaded {
return
}
f.Logger.Warn("serving embedded fallback for app HTML",
"app", app, "url", urlStr, "reason", reason)
}

View file

@ -0,0 +1,184 @@
package apps
import (
"bytes"
"errors"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)
// Server orchestrates app HTML resolution: subdir cascade override → fetch
// or path read → embedded fallback. It does NOT check whether the app is
// available at the request directory — that's AppAvailableAt's job, called
// from dispatch before invoking Serve.
type Server struct {
Root string
Cache *Cache
Fetcher *Fetcher
BuildVer string // baked into X-ZDDC-Source for embedded responses
}
// NewServer constructs a Server.
func NewServer(root string, cache *Cache, fetcher *Fetcher, buildVer string) *Server {
return &Server{
Root: filepath.Clean(root),
Cache: cache,
Fetcher: fetcher,
BuildVer: buildVer,
}
}
// MatchAppHTML returns the canonical app name if requestPath matches a
// "<dir>/<app>.html" pattern for one of the five canonical apps, plus the
// directory (relative to root) the request is rooted at.
//
// Special case: GET / and GET /index.html both resolve to landing.
func MatchAppHTML(requestPath string) (app string, requestDirRel string) {
if requestPath == "" || requestPath == "/" {
return "landing", ""
}
clean := strings.TrimPrefix(requestPath, "/")
clean = strings.TrimSuffix(clean, "/")
if clean == "" {
return "landing", ""
}
dir := filepath.Dir(clean)
if dir == "." {
dir = ""
}
switch filepath.Base(clean) {
case "index.html":
return "landing", dir
case "archive.html":
return "archive", dir
case "transmittal.html":
return "transmittal", dir
case "classifier.html":
return "classifier", dir
case "mdedit.html":
return "mdedit", dir
}
return "", ""
}
// Serve resolves and writes the response. Caller has already verified:
// - no real file exists at the request path
// - AppAvailableAt(root, requestDir, app) is true
// - ACL passes for requestDir
//
// Honors a `?v=<spec>` query parameter as a per-request override on top of
// the cascade. With `?v=` set, the resolved URL must already exist in the
// cache — otherwise the response is 404. This prevents users from
// triggering arbitrary upstream fetches via URL-crafted requests; only
// versions the operator's `.zddc apps:` entries have already pulled in
// (or that the user has manually placed in `_app/`) are reachable.
func (s *Server) Serve(w http.ResponseWriter, r *http.Request, app string, chain zddc.PolicyChain, requestDir string) {
vSpec := strings.TrimSpace(r.URL.Query().Get("v"))
src, hasOverride, err := ResolveWithOverride(chain, app, s.Root, requestDir, vSpec)
if err != nil {
// `?v=` parsing/validation errors are user input → 400.
if vSpec != "" {
http.Error(w, "400 Bad Request — invalid ?v= value: "+err.Error(), http.StatusBadRequest)
return
}
// Malformed `.zddc` spec — operator's fault. Log and serve embedded.
s.Fetcher.Logger.Warn("apps.Resolve failed; serving embedded",
"app", app, "request_dir", requestDir, "err", err)
s.serveEmbedded(w, app, err)
return
}
if !hasOverride {
// No `.zddc apps:` entry anywhere up the chain and no `?v=` either →
// embedded is the authoritative default.
s.serveEmbedded(w, app, nil)
return
}
// Per-request `?v=` is restricted to cache-backed URL sources.
if vSpec != "" {
if !src.IsURL() {
http.Error(w, "400 Bad Request — ?v= requires a URL-form spec", http.StatusBadRequest)
return
}
if s.Cache == nil || !s.Cache.Has(src.URL) {
http.Error(w,
"404 Not Found — version requested via ?v= is not in the local cache.\n"+
"Only versions the deployment has already fetched (via .zddc apps: entries) are servable.\n"+
"Asked for: "+src.URL+"\n",
http.StatusNotFound)
return
}
body, err := s.Cache.Read(src.URL)
if err != nil {
s.Fetcher.Logger.Warn("?v= cache read failed", "url", src.URL, "err", err)
http.Error(w, "500 Internal Server Error", http.StatusInternalServerError)
return
}
s.serveBody(w, r, body, "cache:"+src.URL)
return
}
if !src.IsURL() {
// Path source: read directly, no cache.
body, err := os.ReadFile(src.Path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
s.Fetcher.Logger.Warn("path source missing; serving embedded",
"app", app, "path", src.Path)
} else {
s.Fetcher.Logger.Warn("path source unreadable; serving embedded",
"app", app, "path", src.Path, "err", err)
}
s.serveEmbedded(w, app, err)
return
}
s.serveBody(w, r, body, "path:"+src.Path)
return
}
// URL source: cache hit serves immediately; cache miss fetches once.
body, err := s.Fetcher.Fetch(r.Context(), src.URL)
if err != nil {
s.Fetcher.LogEmbeddedFallback(app, src.URL, err)
s.serveEmbedded(w, app, err)
return
}
sourceTag := "fetch:" + src.URL
if s.Cache != nil && s.Cache.Has(src.URL) {
// Likely served from cache (Has was true when the read started).
// Distinguishing cache-hit from just-fetched is best-effort here.
sourceTag = "cache:" + src.URL
}
s.serveBody(w, r, body, sourceTag)
}
func (s *Server) serveBody(w http.ResponseWriter, r *http.Request, body []byte, sourceHeader string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("X-ZDDC-Source", sourceHeader)
w.Header().Set("Cache-Control", "public, max-age=3600, must-revalidate")
http.ServeContent(w, r, "", time.Time{}, bytes.NewReader(body))
}
func (s *Server) serveEmbedded(w http.ResponseWriter, app string, _ error) {
body := EmbeddedBytes(app)
if len(body) == 0 {
w.Header().Set("Retry-After", "60")
http.Error(w,
"503 Service Unavailable\n\n"+
"This zddc-server has no embedded fallback for "+app+".\n"+
"Rebuild the binary against the latest tool HTMLs.\n",
http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("X-ZDDC-Source", "embedded:"+app+"@"+s.BuildVer)
w.Header().Set("Cache-Control", "public, max-age=300, must-revalidate")
_, _ = w.Write(body)
}

View file

@ -0,0 +1,292 @@
package apps
import (
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)
func TestMatchAppHTML(t *testing.T) {
cases := []struct {
path, wantApp, wantDir string
}{
{"/", "landing", ""},
{"/index.html", "landing", ""},
{"/archive.html", "archive", ""},
{"/Project-X/archive.html", "archive", "Project-X"},
{"/Project-X/Working/mdedit.html", "mdedit", "Project-X/Working"},
{"/foo.html", "", ""},
}
for _, tc := range cases {
t.Run(tc.path, func(t *testing.T) {
gotApp, gotDir := MatchAppHTML(tc.path)
if gotApp != tc.wantApp || gotDir != tc.wantDir {
t.Errorf("got (%q,%q), want (%q,%q)", gotApp, gotDir, tc.wantApp, tc.wantDir)
}
})
}
}
// Build a Server with a fake upstream serving body.
func newTestServer(t *testing.T, body []byte) (*Server, *httptest.Server, string) {
t.Helper()
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(body)
}))
t.Cleanup(upstream.Close)
root := t.TempDir()
cache, err := NewCache(filepath.Join(root, CacheDirName))
if err != nil {
t.Fatal(err)
}
f := NewFetcher(cache, nil)
return NewServer(root, cache, f, "test"), upstream, root
}
func TestServer_NoOverride_ServesEmbedded(t *testing.T) {
srv, _, root := newTestServer(t, []byte("upstream body"))
saved := embeddedArchive
embeddedArchive = []byte("EMBEDDED archive")
defer func() { embeddedArchive = saved }()
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{}}}
rec := httptest.NewRecorder()
srv.Serve(rec, httptest.NewRequest(http.MethodGet, "/archive.html", nil), "archive", chain, root)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d", rec.Code)
}
if !strings.Contains(rec.Body.String(), "EMBEDDED") {
t.Errorf("expected embedded body, got %q", rec.Body.String())
}
if !strings.HasPrefix(rec.Header().Get("X-ZDDC-Source"), "embedded:archive@") {
t.Errorf("X-ZDDC-Source=%q", rec.Header().Get("X-ZDDC-Source"))
}
}
func TestServer_OverrideURL_FetchesAndCaches(t *testing.T) {
body := []byte("from upstream")
srv, up, root := newTestServer(t, body)
chain := zddc.PolicyChain{
Levels: []zddc.ZddcFile{{
Apps: map[string]string{"archive": up.URL + "/archive_stable.html"},
}},
}
rec := httptest.NewRecorder()
srv.Serve(rec, httptest.NewRequest(http.MethodGet, "/archive.html", nil), "archive", chain, root)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d", rec.Code)
}
if rec.Body.String() != string(body) {
t.Errorf("body mismatch")
}
// Cache should be populated.
if !srv.Cache.Has(up.URL + "/archive_stable.html") {
t.Errorf("cache miss after fetch")
}
}
func TestServer_OverrideURL_CacheHitOnSecondCall(t *testing.T) {
var hits atomic.Int64
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
_, _ = w.Write([]byte("body"))
}))
defer upstream.Close()
root := t.TempDir()
cache, _ := NewCache(filepath.Join(root, CacheDirName))
f := NewFetcher(cache, nil)
srv := NewServer(root, cache, f, "test")
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{
Apps: map[string]string{"archive": upstream.URL + "/archive_stable.html"},
}}}
for i := 0; i < 3; i++ {
rec := httptest.NewRecorder()
srv.Serve(rec, httptest.NewRequest(http.MethodGet, "/archive.html", nil), "archive", chain, root)
if rec.Code != http.StatusOK {
t.Fatalf("call %d status=%d", i, rec.Code)
}
}
if hits.Load() != 1 {
t.Errorf("upstream fetched %d times, want exactly 1 (cache forever)", hits.Load())
}
}
func TestServer_PathOverride_ServedDirectly(t *testing.T) {
root := t.TempDir()
pathFile := filepath.Join(root, "local.html")
body := []byte("local archive bytes")
if err := os.WriteFile(pathFile, body, 0o644); err != nil {
t.Fatal(err)
}
cache, _ := NewCache(filepath.Join(root, CacheDirName))
f := NewFetcher(cache, nil)
srv := NewServer(root, cache, f, "test")
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{
{Apps: map[string]string{"archive": "./local.html"}},
}}
rec := httptest.NewRecorder()
srv.Serve(rec, httptest.NewRequest(http.MethodGet, "/archive.html", nil), "archive", chain, root)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d", rec.Code)
}
if rec.Body.String() != string(body) {
t.Errorf("body mismatch")
}
if !strings.HasPrefix(rec.Header().Get("X-ZDDC-Source"), "path:") {
t.Errorf("X-ZDDC-Source=%q", rec.Header().Get("X-ZDDC-Source"))
}
}
func TestServer_FetchFailFallsBackToEmbedded(t *testing.T) {
srv, _, root := newTestServer(t, []byte("ok"))
saved := embeddedArchive
embeddedArchive = []byte("EMBEDDED")
defer func() { embeddedArchive = saved }()
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{
Apps: map[string]string{"archive": "https://no-such.example/archive.html"},
}}}
rec := httptest.NewRecorder()
srv.Serve(rec, httptest.NewRequest(http.MethodGet, "/archive.html", nil), "archive", chain, root)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d (want 200 from embedded)", rec.Code)
}
if !strings.Contains(rec.Body.String(), "EMBEDDED") {
t.Errorf("body did not come from embedded fallback: %q", rec.Body.String())
}
}
// ── ?v= per-request override ─────────────────────────────────────────────
func TestServer_VParam_CacheHitServesFromCache(t *testing.T) {
srv, _, root := newTestServer(t, []byte("ignored"))
// Pre-populate the cache with a known URL.
cachedURL := "https://zddc.varasys.io/releases/archive_beta.html"
cachedBody := []byte("CACHED beta archive")
if err := srv.Cache.Write(cachedURL, cachedBody); err != nil {
t.Fatal(err)
}
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{}}}
rec := httptest.NewRecorder()
srv.Serve(rec, httptest.NewRequest(http.MethodGet, "/archive.html?v=beta", nil), "archive", chain, root)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if rec.Body.String() != string(cachedBody) {
t.Errorf("body=%q, want CACHED bytes", rec.Body.String())
}
if got := rec.Header().Get("X-ZDDC-Source"); got != "cache:"+cachedURL {
t.Errorf("X-ZDDC-Source=%q", got)
}
}
func TestServer_VParam_CacheMissReturns404(t *testing.T) {
srv, _, root := newTestServer(t, []byte("ignored"))
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{}}}
rec := httptest.NewRecorder()
srv.Serve(rec, httptest.NewRequest(http.MethodGet, "/archive.html?v=beta", nil), "archive", chain, root)
if rec.Code != http.StatusNotFound {
t.Fatalf("status=%d (want 404)", rec.Code)
}
if !strings.Contains(rec.Body.String(), "not in the local cache") {
t.Errorf("body should explain cache miss, got %q", rec.Body.String())
}
}
func TestServer_VParam_RejectsPathSource(t *testing.T) {
srv, _, root := newTestServer(t, []byte("ignored"))
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{}}}
rec := httptest.NewRecorder()
srv.Serve(rec, httptest.NewRequest(http.MethodGet, "/archive.html?v=./local.html", nil), "archive", chain, root)
if rec.Code != http.StatusBadRequest {
t.Errorf("status=%d (want 400 for path source via ?v=)", rec.Code)
}
}
func TestServer_VParam_BadSpecReturns400(t *testing.T) {
srv, _, root := newTestServer(t, []byte("ignored"))
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{}}}
rec := httptest.NewRecorder()
srv.Serve(rec, httptest.NewRequest(http.MethodGet, "/archive.html?v=not%20a%20spec", nil), "archive", chain, root)
if rec.Code != http.StatusBadRequest {
t.Errorf("status=%d (want 400)", rec.Code)
}
}
func TestServer_VParam_CombinesWithCascadeURLPrefix(t *testing.T) {
// Cascade has a default URL prefix; ?v=:beta should resolve against it.
srv, _, root := newTestServer(t, []byte("ignored"))
cachedURL := "https://my-mirror.example/releases/archive_beta.html"
if err := srv.Cache.Write(cachedURL, []byte("MIRROR beta")); err != nil {
t.Fatal(err)
}
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{
Apps: map[string]string{"default": "https://my-mirror.example/releases:stable"},
}}}
rec := httptest.NewRecorder()
srv.Serve(rec, httptest.NewRequest(http.MethodGet, "/archive.html?v=:beta", nil), "archive", chain, root)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if rec.Body.String() != "MIRROR beta" {
t.Errorf("body=%q", rec.Body.String())
}
if got := rec.Header().Get("X-ZDDC-Source"); got != "cache:"+cachedURL {
t.Errorf("X-ZDDC-Source=%q (expected mirror URL)", got)
}
}
func TestServer_VParam_OverridesPathTerminalFromCascade(t *testing.T) {
// Operator's cascade specifies a path source. User passes ?v=stable.
// ?v= overrides → resolves to canonical/archive_stable.html, then cache check.
srv, _, root := newTestServer(t, []byte("ignored"))
cachedURL := "https://zddc.varasys.io/releases/archive_stable.html"
if err := srv.Cache.Write(cachedURL, []byte("CACHED stable")); err != nil {
t.Fatal(err)
}
pathFile := filepath.Join(root, "operator-version.html")
if err := os.WriteFile(pathFile, []byte("OPERATOR PATH"), 0o644); err != nil {
t.Fatal(err)
}
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{
Apps: map[string]string{"archive": "./operator-version.html"},
}}}
rec := httptest.NewRecorder()
srv.Serve(rec, httptest.NewRequest(http.MethodGet, "/archive.html?v=stable", nil), "archive", chain, root)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if rec.Body.String() != "CACHED stable" {
t.Errorf("body=%q (expected ?v= override to win)", rec.Body.String())
}
}
func TestServer_VParam_FullURLForm(t *testing.T) {
// `?v=https://my-fork/archive.html` — terminal full URL, must be cached.
srv, _, root := newTestServer(t, []byte("ignored"))
cachedURL := "https://my-fork.example/custom.html"
if err := srv.Cache.Write(cachedURL, []byte("FORK custom")); err != nil {
t.Fatal(err)
}
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{}}}
target := "/archive.html?v=" + url.QueryEscape(cachedURL)
rec := httptest.NewRecorder()
srv.Serve(rec, httptest.NewRequest(http.MethodGet, target, nil), "archive", chain, root)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if rec.Body.String() != "FORK custom" {
t.Errorf("body=%q", rec.Body.String())
}
}

View file

@ -0,0 +1,43 @@
package apps
import "sync"
// singleflightGroup deduplicates concurrent calls keyed by string. If N
// goroutines call Do(key, fn) before the first one returns, fn runs once
// and all callers receive the same (val, err).
//
// Hand-rolled to avoid pulling in golang.org/x/sync — we only need the
// 30-line core, not Forget/DoChan. Pattern is the standard one.
type singleflightGroup struct {
mu sync.Mutex
m map[string]*sfCall
}
type sfCall struct {
done chan struct{}
val any
err error
}
func (g *singleflightGroup) Do(key string, fn func() (any, error)) (any, error) {
g.mu.Lock()
if g.m == nil {
g.m = make(map[string]*sfCall)
}
if c, ok := g.m[key]; ok {
g.mu.Unlock()
<-c.done
return c.val, c.err
}
c := &sfCall{done: make(chan struct{})}
g.m[key] = c
g.mu.Unlock()
c.val, c.err = fn()
close(c.done)
g.mu.Lock()
delete(g.m, key)
g.mu.Unlock()
return c.val, c.err
}

View file

@ -0,0 +1,67 @@
package apps
import (
"sync"
"sync/atomic"
"testing"
"time"
)
func TestSingleflightDedupes(t *testing.T) {
var g singleflightGroup
var calls atomic.Int64
fn := func() (any, error) {
calls.Add(1)
time.Sleep(50 * time.Millisecond) // hold the lock long enough for races
return "result", nil
}
var wg sync.WaitGroup
const N = 50
for i := 0; i < N; i++ {
wg.Add(1)
go func() {
defer wg.Done()
val, err := g.Do("the-key", fn)
if err != nil {
t.Errorf("Do err: %v", err)
return
}
if val.(string) != "result" {
t.Errorf("got %v, want 'result'", val)
}
}()
}
wg.Wait()
if got := calls.Load(); got != 1 {
t.Errorf("fn called %d times, want exactly 1", got)
}
}
func TestSingleflightDifferentKeysParallel(t *testing.T) {
var g singleflightGroup
var calls atomic.Int64
fn := func() (any, error) {
calls.Add(1)
return "ok", nil
}
for _, k := range []string{"a", "b", "c"} {
_, _ = g.Do(k, fn)
}
if got := calls.Load(); got != 3 {
t.Errorf("fn called %d times, want 3", got)
}
}
func TestSingleflightSecondCallAfterFirstResolves(t *testing.T) {
var g singleflightGroup
var calls atomic.Int64
fn := func() (any, error) {
calls.Add(1)
return "x", nil
}
_, _ = g.Do("k", fn)
_, _ = g.Do("k", fn)
if got := calls.Load(); got != 2 {
t.Errorf("fn called %d times, want 2 (second call sees no in-flight entry)", got)
}
}

View file

@ -19,6 +19,11 @@ type Config struct {
IndexPath string // ZDDC_INDEX_PATH — virtual segment name (default .archive) IndexPath string // ZDDC_INDEX_PATH — virtual segment name (default .archive)
EmailHeader string // ZDDC_EMAIL_HEADER — header name for user email (default X-Auth-Request-Email) EmailHeader string // ZDDC_EMAIL_HEADER — header name for user email (default X-Auth-Request-Email)
CORSOrigins []string // ZDDC_CORS_ORIGIN — comma-separated CORS allowlist; default https://zddc.varasys.io; empty disables CORSOrigins []string // ZDDC_CORS_ORIGIN — comma-separated CORS allowlist; default https://zddc.varasys.io; empty disables
// BuildVersion is baked into the X-ZDDC-Source header on embedded
// fallback responses so operators see exactly which binary's
// embedded HTML they're getting. Set at build time via -ldflags.
BuildVersion string
} }
// Load reads configuration from environment variables and validates required fields. // Load reads configuration from environment variables and validates required fields.
@ -32,6 +37,7 @@ func Load() (Config, error) {
IndexPath: getEnv("ZDDC_INDEX_PATH", ".archive"), IndexPath: getEnv("ZDDC_INDEX_PATH", ".archive"),
EmailHeader: getEnv("ZDDC_EMAIL_HEADER", "X-Auth-Request-Email"), EmailHeader: getEnv("ZDDC_EMAIL_HEADER", "X-Auth-Request-Email"),
CORSOrigins: parseCORSOrigins(), CORSOrigins: parseCORSOrigins(),
BuildVersion: getEnv("ZDDC_BUILD_VERSION", "dev"),
} }
if cfg.Root == "" { if cfg.Root == "" {

View file

@ -23,10 +23,23 @@ type ACLRules struct {
// each project root) by ServeProjectList; it surfaces a human-readable name // each project root) by ServeProjectList; it surfaces a human-readable name
// for the project on the landing-page picker. Optional — projects without a // for the project on the landing-page picker. Optional — projects without a
// title fall back to displaying the directory name. // title fall back to displaying the directory name.
//
// Apps is a per-directory cascade override mapping app name → source spec.
// The spec is one of: "stable" / "beta" / "alpha" (channel on the canonical
// upstream), "v0.0.4" / "v0.0" / "v0" (version pin on the canonical
// upstream), an absolute "https://..." URL (custom mirror), or a relative
// or absolute filesystem path (./local.html, /opt/zddc/foo.html).
//
// On a request for a tool HTML, zddc-server walks .zddc files leaf→root
// looking for an Apps entry; first match wins. With no entry anywhere, the
// server serves the version baked into the binary at compile time (//go:embed).
// Fetched URL sources are cached in <ZDDC_ROOT>/_app/; the cache is fetch-once
// and never re-validates — operators delete the file to force a refetch.
type ZddcFile struct { type ZddcFile struct {
ACL ACLRules `yaml:"acl"` ACL ACLRules `yaml:"acl"`
Admins []string `yaml:"admins"` Admins []string `yaml:"admins"`
Title string `yaml:"title"` Title string `yaml:"title"`
Apps map[string]string `yaml:"apps,omitempty"`
} }
// ParseFile reads and parses a .zddc YAML file. // ParseFile reads and parses a .zddc YAML file.

View file

@ -5,6 +5,32 @@ import (
"strings" "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 // ValidatePattern returns an error if pattern is not a syntactically
// well-formed email-glob. The matcher in MatchesPattern is forgiving and // well-formed email-glob. The matcher in MatchesPattern is forgiving and
// will silently fail to match malformed patterns (e.g., "alice@@x" or // will silently fail to match malformed patterns (e.g., "alice@@x" or
@ -86,6 +112,101 @@ func ValidateProjectName(name string) error {
return nil 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 { func ValidateFile(zf ZddcFile) []FieldError {
var errs []FieldError var errs []FieldError
check := func(field string, vals []string) { check := func(field string, vals []string) {
@ -107,5 +228,20 @@ func ValidateFile(zf ZddcFile) []FieldError {
Message: "title exceeds 200 characters", 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 return errs
} }

View file

@ -1,6 +1,8 @@
package zddc package zddc
import "testing" import (
"testing"
)
func TestValidatePattern(t *testing.T) { func TestValidatePattern(t *testing.T) {
cases := []struct { cases := []struct {
@ -64,6 +66,125 @@ func TestValidateFile(t *testing.T) {
} }
} }
func TestValidateAppSourceSpec(t *testing.T) {
cases := []struct {
spec string
ok bool
}{
// Channel shorthand (with and without leading colon)
{"stable", true},
{"beta", true},
{"alpha", true},
{":stable", true},
{":beta", true},
{":alpha", true},
// Version pin shorthand (full, partial, with/without leading 'v')
{"v0.0.4", true},
{"0.0.4", true},
{"v0.0", true},
{"0.0", true},
{"v0", true},
{"0", true},
{"v1.2.3", true},
{":v0.0.4", true},
{":0.0.4", true},
// URLs
{"https://zddc.varasys.io/releases/archive_stable.html", true},
{"http://my-fork.example.com/archive.html", true},
{"https://my-mirror.example/releases", true}, // URL-prefix only
{"https://my-mirror.example/releases:stable", true}, // URL-prefix + channel
{"https://my-mirror.example/releases:v0.0.4", true}, // URL-prefix + version
{"https://my-mirror.example:8080/releases", true}, // URL with port
{"https://my-mirror.example:8080/releases:stable", true}, // URL with port + channel
// Paths
{"/abs/path.html", true},
{"./local.html", true},
{"../sibling.html", true},
// Errors
{"", false},
{" stable", false},
{"stable ", false},
{"with space", false},
{"https://", false},
{"https://host/path/file.html:stable", false}, // .html URL with suffix
{"random-thing", false},
{":", false},
{":random", false},
{"v", false},
{"v0.", false},
{".0.0", false},
{"v0.0.0.0", false},
{"v0.a.0", false},
{"https://my-mirror.example/releases:bogus", false}, // bad channel suffix
}
for _, tc := range cases {
t.Run(tc.spec, func(t *testing.T) {
err := ValidateAppSourceSpec(tc.spec)
if tc.ok && err != nil {
t.Errorf("ValidateAppSourceSpec(%q) = %v, want nil", tc.spec, err)
}
if !tc.ok && err == nil {
t.Errorf("ValidateAppSourceSpec(%q) = nil, want error", tc.spec)
}
})
}
}
func TestIsValidAppsKey(t *testing.T) {
cases := []struct {
key string
ok bool
}{
{"default", true},
{"archive", true},
{"transmittal", true},
{"classifier", true},
{"mdedit", true},
{"landing", true},
{"unknown", false},
{"", false},
{"DEFAULT", false}, // case-sensitive
}
for _, tc := range cases {
t.Run(tc.key, func(t *testing.T) {
if got := IsValidAppsKey(tc.key); got != tc.ok {
t.Errorf("IsValidAppsKey(%q) = %v, want %v", tc.key, got, tc.ok)
}
})
}
}
func TestValidateFile_Apps(t *testing.T) {
zf := ZddcFile{
Apps: map[string]string{
"archive": "stable", // ok
"classifier": "v0.0.4", // ok
"default": "https://zddc.varasys.io/releases:stable", // ok (default key + URL+channel)
"transmittal": ":beta", // ok (channel-only)
"mdedit": "https://my-mirror.example/releases", // ok (URL-prefix only)
"unknown": "stable", // unknown app
"landing": "what is this", // bad spec
},
}
errs := ValidateFile(zf)
want := map[string]bool{
"apps.unknown": false,
"apps.landing": false,
}
for _, e := range errs {
if _, ok := want[e.Field]; ok {
want[e.Field] = true
} else {
t.Errorf("unexpected error field: %q (%s)", e.Field, e.Message)
}
}
for f, seen := range want {
if !seen {
t.Errorf("missing error for field %q (got: %+v)", f, errs)
}
}
}
func TestValidateProjectName(t *testing.T) { func TestValidateProjectName(t *testing.T) {
cases := []struct { cases := []struct {
name string name string