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:
parent
9b3b11fc20
commit
8b6a2dc3e3
23 changed files with 38478 additions and 10 deletions
|
|
@ -12,6 +12,7 @@ import (
|
|||
"syscall"
|
||||
"time"
|
||||
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/apps"
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/archive"
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/handler"
|
||||
|
|
@ -40,6 +41,13 @@ func main() {
|
|||
}
|
||||
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
|
||||
tlsCfg, useTLS, err := tlsutil.TLSConfig(cfg)
|
||||
if err != nil {
|
||||
|
|
@ -63,6 +71,7 @@ func main() {
|
|||
}()
|
||||
}
|
||||
|
||||
|
||||
// HTTP handler
|
||||
mux := http.NewServeMux()
|
||||
// Middleware chain (outermost → innermost):
|
||||
|
|
@ -78,7 +87,7 @@ func main() {
|
|||
// CORSMiddleware — Origin / preflight handling.
|
||||
// dispatch — the actual request handler.
|
||||
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{
|
||||
|
|
@ -117,8 +126,20 @@ func main() {
|
|||
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.
|
||||
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
|
||||
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
|
||||
// recognized virtual prefixes (.profile handled above, cfg.IndexPath
|
||||
// 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 {
|
||||
if seg == "" || !strings.HasPrefix(seg, ".") {
|
||||
if seg == "" {
|
||||
continue
|
||||
}
|
||||
if seg == apps.CacheDirName {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if !strings.HasPrefix(seg, ".") {
|
||||
continue
|
||||
}
|
||||
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
|
||||
cleanPath := filepath.FromSlash(strings.TrimPrefix(urlPath, "/"))
|
||||
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)
|
||||
if err != nil {
|
||||
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)
|
||||
} else {
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
|
|
|
|||
|
|
@ -3,13 +3,17 @@ package main
|
|||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/apps"
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/archive"
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/handler"
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
||||
)
|
||||
|
||||
// TestDispatchHidesDotPrefixedSegments asserts the dispatch() guard that
|
||||
|
|
@ -74,7 +78,7 @@ func TestDispatchHidesDotPrefixedSegments(t *testing.T) {
|
|||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
dispatch(cfg, idx, ring, rec, req)
|
||||
dispatch(cfg, idx, ring, nil, rec, req)
|
||||
if rec.Code != tc.wantStatus {
|
||||
t.Errorf("path=%q status=%d want=%d body=%q",
|
||||
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) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(path, 0o755); err != nil {
|
||||
|
|
|
|||
401
zddc/internal/apps/apps.go
Normal file
401
zddc/internal/apps/apps.go
Normal 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
|
||||
}
|
||||
426
zddc/internal/apps/apps_test.go
Normal file
426
zddc/internal/apps/apps_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
71
zddc/internal/apps/availability.go
Normal file
71
zddc/internal/apps/availability.go
Normal 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
|
||||
}
|
||||
61
zddc/internal/apps/availability_test.go
Normal file
61
zddc/internal/apps/availability_test.go
Normal 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
165
zddc/internal/apps/cache.go
Normal 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
|
||||
}
|
||||
116
zddc/internal/apps/cache_test.go
Normal file
116
zddc/internal/apps/cache_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
53
zddc/internal/apps/embed.go
Normal file
53
zddc/internal/apps/embed.go
Normal 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
|
||||
}
|
||||
8517
zddc/internal/apps/embedded/archive.html
Normal file
8517
zddc/internal/apps/embedded/archive.html
Normal file
File diff suppressed because it is too large
Load diff
7562
zddc/internal/apps/embedded/classifier.html
Normal file
7562
zddc/internal/apps/embedded/classifier.html
Normal file
File diff suppressed because it is too large
Load diff
2231
zddc/internal/apps/embedded/index.html
Normal file
2231
zddc/internal/apps/embedded/index.html
Normal file
File diff suppressed because it is too large
Load diff
6090
zddc/internal/apps/embedded/mdedit.html
Normal file
6090
zddc/internal/apps/embedded/mdedit.html
Normal file
File diff suppressed because one or more lines are too long
11626
zddc/internal/apps/embedded/transmittal.html
Normal file
11626
zddc/internal/apps/embedded/transmittal.html
Normal file
File diff suppressed because it is too large
Load diff
102
zddc/internal/apps/fetch.go
Normal file
102
zddc/internal/apps/fetch.go
Normal 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)
|
||||
}
|
||||
184
zddc/internal/apps/handler.go
Normal file
184
zddc/internal/apps/handler.go
Normal 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)
|
||||
}
|
||||
292
zddc/internal/apps/handler_test.go
Normal file
292
zddc/internal/apps/handler_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
43
zddc/internal/apps/singleflight.go
Normal file
43
zddc/internal/apps/singleflight.go
Normal 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
|
||||
}
|
||||
67
zddc/internal/apps/singleflight_test.go
Normal file
67
zddc/internal/apps/singleflight_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -19,6 +19,11 @@ type Config struct {
|
|||
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)
|
||||
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.
|
||||
|
|
@ -30,8 +35,9 @@ func Load() (Config, error) {
|
|||
TLSKey: os.Getenv("ZDDC_TLS_KEY"),
|
||||
LogLevel: getEnv("ZDDC_LOG_LEVEL", "info"),
|
||||
IndexPath: getEnv("ZDDC_INDEX_PATH", ".archive"),
|
||||
EmailHeader: getEnv("ZDDC_EMAIL_HEADER", "X-Auth-Request-Email"),
|
||||
CORSOrigins: parseCORSOrigins(),
|
||||
EmailHeader: getEnv("ZDDC_EMAIL_HEADER", "X-Auth-Request-Email"),
|
||||
CORSOrigins: parseCORSOrigins(),
|
||||
BuildVersion: getEnv("ZDDC_BUILD_VERSION", "dev"),
|
||||
}
|
||||
|
||||
if cfg.Root == "" {
|
||||
|
|
|
|||
|
|
@ -23,10 +23,23 @@ type ACLRules struct {
|
|||
// each project root) by ServeProjectList; it surfaces a human-readable name
|
||||
// for the project on the landing-page picker. Optional — projects without a
|
||||
// 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 {
|
||||
ACL ACLRules `yaml:"acl"`
|
||||
Admins []string `yaml:"admins"`
|
||||
Title string `yaml:"title"`
|
||||
ACL ACLRules `yaml:"acl"`
|
||||
Admins []string `yaml:"admins"`
|
||||
Title string `yaml:"title"`
|
||||
Apps map[string]string `yaml:"apps,omitempty"`
|
||||
}
|
||||
|
||||
// ParseFile reads and parses a .zddc YAML file.
|
||||
|
|
|
|||
|
|
@ -5,6 +5,32 @@ import (
|
|||
"strings"
|
||||
)
|
||||
|
||||
// AppNames is the canonical set of app HTML files the server resolves
|
||||
// via the apps fetch+cache subsystem. Order is stable for reproducible
|
||||
// admin-UI rendering.
|
||||
var AppNames = []string{"archive", "transmittal", "classifier", "mdedit", "landing"}
|
||||
|
||||
// AppsDefaultKey is the special apps-map key that provides the baseline
|
||||
// URL prefix and channel for any app not overridden per-name. Cascades
|
||||
// through .zddc files like a per-app entry.
|
||||
const AppsDefaultKey = "default"
|
||||
|
||||
// IsKnownApp reports whether name is one of the canonical apps.
|
||||
func IsKnownApp(name string) bool {
|
||||
for _, n := range AppNames {
|
||||
if n == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsValidAppsKey reports whether name is acceptable as a key in the
|
||||
// `apps:` map — either a canonical app or the special "default" key.
|
||||
func IsValidAppsKey(name string) bool {
|
||||
return name == AppsDefaultKey || IsKnownApp(name)
|
||||
}
|
||||
|
||||
// ValidatePattern returns an error if pattern is not a syntactically
|
||||
// well-formed email-glob. The matcher in MatchesPattern is forgiving and
|
||||
// will silently fail to match malformed patterns (e.g., "alice@@x" or
|
||||
|
|
@ -86,6 +112,101 @@ func ValidateProjectName(name string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// ValidateAppSourceSpec returns nil if spec is a syntactically well-formed
|
||||
// source spec accepted by apps.ParseSpec. It checks the string shape only —
|
||||
// it does not verify URLs are reachable or paths exist.
|
||||
//
|
||||
// Accepted forms:
|
||||
// - "stable" / "beta" / "alpha" / ":stable" / ":beta" / ":alpha" (channel)
|
||||
// - "v0.0.4" / "0.0.4" / "v0.0" / "0.0" / "v0" / "0" / ":v0.0.4" (version)
|
||||
// - "https://host/path" (URL prefix)
|
||||
// - "https://host/path:stable" (URL prefix + channel)
|
||||
// - "https://host/path/file.html" (terminal full URL)
|
||||
// - "/abs/path.html" / "./rel/path.html" / "../sibling.html" (path)
|
||||
func ValidateAppSourceSpec(spec string) error {
|
||||
if spec == "" {
|
||||
return fmt.Errorf("source spec is empty")
|
||||
}
|
||||
if strings.ContainsAny(spec, " \t\n\r") {
|
||||
return fmt.Errorf("source spec contains whitespace")
|
||||
}
|
||||
|
||||
// Path forms.
|
||||
if strings.HasPrefix(spec, "/") ||
|
||||
strings.HasPrefix(spec, "./") ||
|
||||
strings.HasPrefix(spec, "../") {
|
||||
return nil
|
||||
}
|
||||
|
||||
// URL forms.
|
||||
if strings.HasPrefix(spec, "https://") || strings.HasPrefix(spec, "http://") {
|
||||
return validateURLSpec(spec)
|
||||
}
|
||||
|
||||
// Channel-or-version (with optional leading colon).
|
||||
chanPart := strings.TrimPrefix(spec, ":")
|
||||
if chanPart == "" {
|
||||
return fmt.Errorf("empty channel after ':'")
|
||||
}
|
||||
return validateChannelOrVersion(chanPart)
|
||||
}
|
||||
|
||||
// validateURLSpec checks the URL-prefix or full-URL form. Splits on the
|
||||
// last `:` after the last `/` (matching apps.parseURLSpec behavior).
|
||||
func validateURLSpec(spec string) error {
|
||||
// Minimal sanity check on URL shape.
|
||||
if len(spec) <= len("https://") {
|
||||
return fmt.Errorf("URL is missing host")
|
||||
}
|
||||
lastSlash := strings.LastIndex(spec, "/")
|
||||
if lastSlash < 0 {
|
||||
return fmt.Errorf("invalid URL %q: missing path separator", spec)
|
||||
}
|
||||
afterSlash := spec[lastSlash+1:]
|
||||
colonInTail := strings.LastIndex(afterSlash, ":")
|
||||
urlPart, suffixPart := spec, ""
|
||||
if colonInTail >= 0 {
|
||||
urlPart = spec[:lastSlash+1+colonInTail]
|
||||
suffixPart = afterSlash[colonInTail+1:]
|
||||
}
|
||||
if strings.HasSuffix(urlPart, ".html") {
|
||||
if suffixPart != "" {
|
||||
return fmt.Errorf("URL ends in .html but has %q suffix", ":"+suffixPart)
|
||||
}
|
||||
return nil // terminal full URL
|
||||
}
|
||||
if suffixPart != "" {
|
||||
return validateChannelOrVersion(suffixPart)
|
||||
}
|
||||
return nil // URL-prefix only
|
||||
}
|
||||
|
||||
// validateChannelOrVersion enforces the channel/version shape.
|
||||
func validateChannelOrVersion(s string) error {
|
||||
if s == "stable" || s == "beta" || s == "alpha" {
|
||||
return nil
|
||||
}
|
||||
rest := strings.TrimPrefix(s, "v")
|
||||
if rest == "" {
|
||||
return fmt.Errorf("unrecognized source spec %q", s)
|
||||
}
|
||||
parts := strings.Split(rest, ".")
|
||||
if len(parts) > 3 {
|
||||
return fmt.Errorf("version has too many dots: %q", s)
|
||||
}
|
||||
for _, p := range parts {
|
||||
if p == "" {
|
||||
return fmt.Errorf("version has empty component: %q", s)
|
||||
}
|
||||
for _, r := range p {
|
||||
if r < '0' || r > '9' {
|
||||
return fmt.Errorf("unrecognized source spec %q", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateFile(zf ZddcFile) []FieldError {
|
||||
var errs []FieldError
|
||||
check := func(field string, vals []string) {
|
||||
|
|
@ -107,5 +228,20 @@ func ValidateFile(zf ZddcFile) []FieldError {
|
|||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
package zddc
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidatePattern(t *testing.T) {
|
||||
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) {
|
||||
cases := []struct {
|
||||
name string
|
||||
|
|
|
|||
Loading…
Reference in a new issue