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"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/apps"
|
||||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/archive"
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/archive"
|
||||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
|
||||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/handler"
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/handler"
|
||||||
|
|
@ -40,6 +41,13 @@ func main() {
|
||||||
}
|
}
|
||||||
slog.Info("archive index built", "duration", time.Since(start))
|
slog.Info("archive index built", "duration", time.Since(start))
|
||||||
|
|
||||||
|
// Apps fetch+cache subsystem.
|
||||||
|
appsServer, err := setupApps(cfg)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to set up apps subsystem", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
// TLS config
|
// TLS config
|
||||||
tlsCfg, useTLS, err := tlsutil.TLSConfig(cfg)
|
tlsCfg, useTLS, err := tlsutil.TLSConfig(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -63,6 +71,7 @@ func main() {
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// HTTP handler
|
// HTTP handler
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
// Middleware chain (outermost → innermost):
|
// Middleware chain (outermost → innermost):
|
||||||
|
|
@ -78,7 +87,7 @@ func main() {
|
||||||
// CORSMiddleware — Origin / preflight handling.
|
// CORSMiddleware — Origin / preflight handling.
|
||||||
// dispatch — the actual request handler.
|
// dispatch — the actual request handler.
|
||||||
mux.Handle("/", handler.ACLMiddleware(cfg, handler.AccessLogMiddleware(handler.CORSMiddleware(cfg, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
mux.Handle("/", handler.ACLMiddleware(cfg, handler.AccessLogMiddleware(handler.CORSMiddleware(cfg, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
dispatch(cfg, idx, logRing, w, r)
|
dispatch(cfg, idx, logRing, appsServer, w, r)
|
||||||
})))))
|
})))))
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
|
|
@ -117,8 +126,20 @@ func main() {
|
||||||
slog.Info("stopped")
|
slog.Info("stopped")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// setupApps creates the cache + fetcher + server. No seeding, no refresh,
|
||||||
|
// no admin UI — the server fetches once on first request, caches forever
|
||||||
|
// in <ZDDC_ROOT>/_app/, and falls back to the embedded HTML on any failure.
|
||||||
|
func setupApps(cfg config.Config) (*apps.Server, error) {
|
||||||
|
cache, err := apps.NewCache(filepath.Join(cfg.Root, apps.CacheDirName))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create cache: %w", err)
|
||||||
|
}
|
||||||
|
fetcher := apps.NewFetcher(cache, slog.Default())
|
||||||
|
return apps.NewServer(cfg.Root, cache, fetcher, cfg.BuildVersion), nil
|
||||||
|
}
|
||||||
|
|
||||||
// dispatch routes a request to the appropriate handler.
|
// dispatch routes a request to the appropriate handler.
|
||||||
func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, w http.ResponseWriter, r *http.Request) {
|
func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, appsSrv *apps.Server, w http.ResponseWriter, r *http.Request) {
|
||||||
urlPath := r.URL.Path
|
urlPath := r.URL.Path
|
||||||
email := handler.EmailFromContext(r)
|
email := handler.EmailFromContext(r)
|
||||||
|
|
||||||
|
|
@ -150,8 +171,19 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, w ht
|
||||||
// the same Azure Files PVC as served data) cannot be fetched. The
|
// the same Azure Files PVC as served data) cannot be fetched. The
|
||||||
// recognized virtual prefixes (.profile handled above, cfg.IndexPath
|
// recognized virtual prefixes (.profile handled above, cfg.IndexPath
|
||||||
// handled below) are explicitly allowed through.
|
// handled below) are explicitly allowed through.
|
||||||
|
//
|
||||||
|
// Also reserve the apps cache directory (`_app`): the cached HTML files
|
||||||
|
// there must be served via the apps resolver (with proper headers and
|
||||||
|
// ACL), never raw at /_app/...html.
|
||||||
for _, seg := range segments {
|
for _, seg := range segments {
|
||||||
if seg == "" || !strings.HasPrefix(seg, ".") {
|
if seg == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if seg == apps.CacheDirName {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(seg, ".") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if seg == cfg.IndexPath {
|
if seg == cfg.IndexPath {
|
||||||
|
|
@ -175,6 +207,25 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, w ht
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apps resolution for the root landing path: GET / or /index.html with
|
||||||
|
// no real index.html on disk → serve via apps.Serve("landing"). The
|
||||||
|
// other four apps are caught by the "stat fails → app HTML?" branch
|
||||||
|
// below, which only triggers when no concrete file is at the URL path.
|
||||||
|
if appsSrv != nil && (urlPath == "/" || urlPath == "/index.html") {
|
||||||
|
realIndex := filepath.Join(cfg.Root, "index.html")
|
||||||
|
if _, err := os.Stat(realIndex); os.IsNotExist(err) {
|
||||||
|
chain, _ := zddc.EffectivePolicy(cfg.Root, cfg.Root)
|
||||||
|
if !zddc.AllowedWithChain(chain, email) {
|
||||||
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if apps.AppAvailableAt(cfg.Root, cfg.Root, "landing") {
|
||||||
|
appsSrv.Serve(w, r, "landing", chain, cfg.Root)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Resolve the physical path
|
// Resolve the physical path
|
||||||
cleanPath := filepath.FromSlash(strings.TrimPrefix(urlPath, "/"))
|
cleanPath := filepath.FromSlash(strings.TrimPrefix(urlPath, "/"))
|
||||||
absPath := filepath.Join(cfg.Root, cleanPath)
|
absPath := filepath.Join(cfg.Root, cleanPath)
|
||||||
|
|
@ -189,6 +240,25 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, w ht
|
||||||
info, err := os.Stat(absPath)
|
info, err := os.Stat(absPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
|
// File doesn't exist at this path. If the URL matches one of
|
||||||
|
// the five canonical app HTML names AND the request directory
|
||||||
|
// is one where that app is available (Incoming/Working/Staging
|
||||||
|
// for classifier/mdedit/transmittal, anywhere for archive,
|
||||||
|
// root only for landing), resolve via the apps subsystem.
|
||||||
|
if appsSrv != nil {
|
||||||
|
if app, requestDirRel := apps.MatchAppHTML(urlPath); app != "" {
|
||||||
|
requestDir := filepath.Join(cfg.Root, filepath.FromSlash(requestDirRel))
|
||||||
|
if apps.AppAvailableAt(cfg.Root, requestDir, app) {
|
||||||
|
chain, _ := zddc.EffectivePolicy(cfg.Root, requestDir)
|
||||||
|
if !zddc.AllowedWithChain(chain, email) {
|
||||||
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
appsSrv.Serve(w, r, app, chain, requestDir)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
http.Error(w, "Not Found", http.StatusNotFound)
|
http.Error(w, "Not Found", http.StatusNotFound)
|
||||||
} else {
|
} else {
|
||||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,17 @@ package main
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/apps"
|
||||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/archive"
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/archive"
|
||||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
|
||||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/handler"
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/handler"
|
||||||
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestDispatchHidesDotPrefixedSegments asserts the dispatch() guard that
|
// TestDispatchHidesDotPrefixedSegments asserts the dispatch() guard that
|
||||||
|
|
@ -74,7 +78,7 @@ func TestDispatchHidesDotPrefixedSegments(t *testing.T) {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
|
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
dispatch(cfg, idx, ring, rec, req)
|
dispatch(cfg, idx, ring, nil, rec, req)
|
||||||
if rec.Code != tc.wantStatus {
|
if rec.Code != tc.wantStatus {
|
||||||
t.Errorf("path=%q status=%d want=%d body=%q",
|
t.Errorf("path=%q status=%d want=%d body=%q",
|
||||||
tc.path, rec.Code, tc.wantStatus, rec.Body.String())
|
tc.path, rec.Code, tc.wantStatus, rec.Body.String())
|
||||||
|
|
@ -83,6 +87,117 @@ func TestDispatchHidesDotPrefixedSegments(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestDispatchAppsResolution drives the full apps fetch+cache flow through
|
||||||
|
// dispatch() with a fake upstream. Confirms that:
|
||||||
|
// - GET / serves the landing app from the apps subsystem
|
||||||
|
// - GET /archive.html serves the archive app via fetch+cache
|
||||||
|
// - second GET /archive.html serves from cache (X-ZDDC-Source: cache:)
|
||||||
|
// - direct URL access to /_zddc/... is rejected
|
||||||
|
func TestDispatchAppsResolution(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
|
||||||
|
body := []byte("<!doctype html>archive content")
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("ETag", `"v1"`)
|
||||||
|
_, _ = w.Write(body)
|
||||||
|
}))
|
||||||
|
defer upstream.Close()
|
||||||
|
upstreamURL, _ := url.Parse(upstream.URL)
|
||||||
|
upstreamHost := upstreamURL.Host
|
||||||
|
if i := strings.Index(upstreamHost, ":"); i >= 0 {
|
||||||
|
upstreamHost = upstreamHost[:i]
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = upstreamHost // referenced below
|
||||||
|
|
||||||
|
// Seed root .zddc with subdir-cascade Apps entries pointing at the
|
||||||
|
// fake upstream. Allow all email patterns (anonymous) so the test
|
||||||
|
// doesn't have to set up email headers.
|
||||||
|
zf := zddc.ZddcFile{
|
||||||
|
ACL: zddc.ACLRules{Allow: []string{"*"}},
|
||||||
|
Apps: map[string]string{
|
||||||
|
"archive": upstream.URL + "/archive_stable.html",
|
||||||
|
"transmittal": upstream.URL + "/transmittal_stable.html",
|
||||||
|
"classifier": upstream.URL + "/classifier_stable.html",
|
||||||
|
"mdedit": upstream.URL + "/mdedit_stable.html",
|
||||||
|
"landing": upstream.URL + "/landing_stable.html",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := zddc.WriteFile(root, zf); err != nil {
|
||||||
|
t.Fatalf("WriteFile: %v", err)
|
||||||
|
}
|
||||||
|
// Create folder convention dirs so classifier/mdedit/transmittal
|
||||||
|
// availability rules pass for the test paths used below.
|
||||||
|
mustMkdir(t, filepath.Join(root, "Project-A", "Working"))
|
||||||
|
|
||||||
|
idx, err := archive.BuildIndex(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildIndex: %v", err)
|
||||||
|
}
|
||||||
|
cfg := config.Config{
|
||||||
|
Root: root,
|
||||||
|
IndexPath: ".archive",
|
||||||
|
EmailHeader: "X-Auth-Request-Email",
|
||||||
|
BuildVersion: "test-build",
|
||||||
|
}
|
||||||
|
ring := handler.NewLogRing(10)
|
||||||
|
|
||||||
|
appsSrv, err := setupApps(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("setupApps: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /archive.html → fetched from upstream (archive is available everywhere)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/archive.html", nil)
|
||||||
|
dispatch(cfg, idx, ring, appsSrv, rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("first /archive.html: status=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if rec.Body.String() != string(body) {
|
||||||
|
t.Errorf("first /archive.html: body mismatch")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /archive.html again → cache hit (no new upstream fetch)
|
||||||
|
rec2 := httptest.NewRecorder()
|
||||||
|
dispatch(cfg, idx, ring, appsSrv, rec2, httptest.NewRequest(http.MethodGet, "/archive.html", nil))
|
||||||
|
if rec2.Code != http.StatusOK {
|
||||||
|
t.Errorf("second /archive.html: status=%d", rec2.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET / → landing
|
||||||
|
rec3 := httptest.NewRecorder()
|
||||||
|
dispatch(cfg, idx, ring, appsSrv, rec3, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||||
|
if rec3.Code != http.StatusOK {
|
||||||
|
t.Errorf("GET /: status=%d", rec3.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Direct URL access to /_app/ → 404
|
||||||
|
rec4 := httptest.NewRecorder()
|
||||||
|
dispatch(cfg, idx, ring, appsSrv, rec4, httptest.NewRequest(http.MethodGet, "/_app/foo.html", nil))
|
||||||
|
if rec4.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("/_app/ direct: status=%d, want 404", rec4.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Folder availability rules: classifier should NOT be served at root
|
||||||
|
// (root has no Incoming/Working/Staging ancestor), but SHOULD work in
|
||||||
|
// /Project-A/Working/.
|
||||||
|
rec5 := httptest.NewRecorder()
|
||||||
|
dispatch(cfg, idx, ring, appsSrv, rec5, httptest.NewRequest(http.MethodGet, "/classifier.html", nil))
|
||||||
|
if rec5.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("/classifier.html at root: status=%d, want 404 (not in Incoming/Working/Staging)", rec5.Code)
|
||||||
|
}
|
||||||
|
rec6 := httptest.NewRecorder()
|
||||||
|
dispatch(cfg, idx, ring, appsSrv, rec6, httptest.NewRequest(http.MethodGet, "/Project-A/Working/classifier.html", nil))
|
||||||
|
if rec6.Code != http.StatusOK {
|
||||||
|
t.Errorf("/Project-A/Working/classifier.html: status=%d, want 200", rec6.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// silence "imported and not used" if apps not referenced elsewhere — keep
|
||||||
|
// import even when we trim test cases later.
|
||||||
|
var _ = apps.DefaultUpstream
|
||||||
|
|
||||||
func mustMkdir(t *testing.T, path string) {
|
func mustMkdir(t *testing.T, path string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
if err := os.MkdirAll(path, 0o755); err != nil {
|
if err := os.MkdirAll(path, 0o755); err != nil {
|
||||||
|
|
|
||||||
401
zddc/internal/apps/apps.go
Normal file
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)
|
IndexPath string // ZDDC_INDEX_PATH — virtual segment name (default .archive)
|
||||||
EmailHeader string // ZDDC_EMAIL_HEADER — header name for user email (default X-Auth-Request-Email)
|
EmailHeader string // ZDDC_EMAIL_HEADER — header name for user email (default X-Auth-Request-Email)
|
||||||
CORSOrigins []string // ZDDC_CORS_ORIGIN — comma-separated CORS allowlist; default https://zddc.varasys.io; empty disables
|
CORSOrigins []string // ZDDC_CORS_ORIGIN — comma-separated CORS allowlist; default https://zddc.varasys.io; empty disables
|
||||||
|
|
||||||
|
// BuildVersion is baked into the X-ZDDC-Source header on embedded
|
||||||
|
// fallback responses so operators see exactly which binary's
|
||||||
|
// embedded HTML they're getting. Set at build time via -ldflags.
|
||||||
|
BuildVersion string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load reads configuration from environment variables and validates required fields.
|
// Load reads configuration from environment variables and validates required fields.
|
||||||
|
|
@ -32,6 +37,7 @@ func Load() (Config, error) {
|
||||||
IndexPath: getEnv("ZDDC_INDEX_PATH", ".archive"),
|
IndexPath: getEnv("ZDDC_INDEX_PATH", ".archive"),
|
||||||
EmailHeader: getEnv("ZDDC_EMAIL_HEADER", "X-Auth-Request-Email"),
|
EmailHeader: getEnv("ZDDC_EMAIL_HEADER", "X-Auth-Request-Email"),
|
||||||
CORSOrigins: parseCORSOrigins(),
|
CORSOrigins: parseCORSOrigins(),
|
||||||
|
BuildVersion: getEnv("ZDDC_BUILD_VERSION", "dev"),
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.Root == "" {
|
if cfg.Root == "" {
|
||||||
|
|
|
||||||
|
|
@ -23,10 +23,23 @@ type ACLRules struct {
|
||||||
// each project root) by ServeProjectList; it surfaces a human-readable name
|
// each project root) by ServeProjectList; it surfaces a human-readable name
|
||||||
// for the project on the landing-page picker. Optional — projects without a
|
// for the project on the landing-page picker. Optional — projects without a
|
||||||
// title fall back to displaying the directory name.
|
// title fall back to displaying the directory name.
|
||||||
|
//
|
||||||
|
// Apps is a per-directory cascade override mapping app name → source spec.
|
||||||
|
// The spec is one of: "stable" / "beta" / "alpha" (channel on the canonical
|
||||||
|
// upstream), "v0.0.4" / "v0.0" / "v0" (version pin on the canonical
|
||||||
|
// upstream), an absolute "https://..." URL (custom mirror), or a relative
|
||||||
|
// or absolute filesystem path (./local.html, /opt/zddc/foo.html).
|
||||||
|
//
|
||||||
|
// On a request for a tool HTML, zddc-server walks .zddc files leaf→root
|
||||||
|
// looking for an Apps entry; first match wins. With no entry anywhere, the
|
||||||
|
// server serves the version baked into the binary at compile time (//go:embed).
|
||||||
|
// Fetched URL sources are cached in <ZDDC_ROOT>/_app/; the cache is fetch-once
|
||||||
|
// and never re-validates — operators delete the file to force a refetch.
|
||||||
type ZddcFile struct {
|
type ZddcFile struct {
|
||||||
ACL ACLRules `yaml:"acl"`
|
ACL ACLRules `yaml:"acl"`
|
||||||
Admins []string `yaml:"admins"`
|
Admins []string `yaml:"admins"`
|
||||||
Title string `yaml:"title"`
|
Title string `yaml:"title"`
|
||||||
|
Apps map[string]string `yaml:"apps,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseFile reads and parses a .zddc YAML file.
|
// ParseFile reads and parses a .zddc YAML file.
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,32 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// AppNames is the canonical set of app HTML files the server resolves
|
||||||
|
// via the apps fetch+cache subsystem. Order is stable for reproducible
|
||||||
|
// admin-UI rendering.
|
||||||
|
var AppNames = []string{"archive", "transmittal", "classifier", "mdedit", "landing"}
|
||||||
|
|
||||||
|
// AppsDefaultKey is the special apps-map key that provides the baseline
|
||||||
|
// URL prefix and channel for any app not overridden per-name. Cascades
|
||||||
|
// through .zddc files like a per-app entry.
|
||||||
|
const AppsDefaultKey = "default"
|
||||||
|
|
||||||
|
// IsKnownApp reports whether name is one of the canonical apps.
|
||||||
|
func IsKnownApp(name string) bool {
|
||||||
|
for _, n := range AppNames {
|
||||||
|
if n == name {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsValidAppsKey reports whether name is acceptable as a key in the
|
||||||
|
// `apps:` map — either a canonical app or the special "default" key.
|
||||||
|
func IsValidAppsKey(name string) bool {
|
||||||
|
return name == AppsDefaultKey || IsKnownApp(name)
|
||||||
|
}
|
||||||
|
|
||||||
// ValidatePattern returns an error if pattern is not a syntactically
|
// ValidatePattern returns an error if pattern is not a syntactically
|
||||||
// well-formed email-glob. The matcher in MatchesPattern is forgiving and
|
// well-formed email-glob. The matcher in MatchesPattern is forgiving and
|
||||||
// will silently fail to match malformed patterns (e.g., "alice@@x" or
|
// will silently fail to match malformed patterns (e.g., "alice@@x" or
|
||||||
|
|
@ -86,6 +112,101 @@ func ValidateProjectName(name string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ValidateAppSourceSpec returns nil if spec is a syntactically well-formed
|
||||||
|
// source spec accepted by apps.ParseSpec. It checks the string shape only —
|
||||||
|
// it does not verify URLs are reachable or paths exist.
|
||||||
|
//
|
||||||
|
// Accepted forms:
|
||||||
|
// - "stable" / "beta" / "alpha" / ":stable" / ":beta" / ":alpha" (channel)
|
||||||
|
// - "v0.0.4" / "0.0.4" / "v0.0" / "0.0" / "v0" / "0" / ":v0.0.4" (version)
|
||||||
|
// - "https://host/path" (URL prefix)
|
||||||
|
// - "https://host/path:stable" (URL prefix + channel)
|
||||||
|
// - "https://host/path/file.html" (terminal full URL)
|
||||||
|
// - "/abs/path.html" / "./rel/path.html" / "../sibling.html" (path)
|
||||||
|
func ValidateAppSourceSpec(spec string) error {
|
||||||
|
if spec == "" {
|
||||||
|
return fmt.Errorf("source spec is empty")
|
||||||
|
}
|
||||||
|
if strings.ContainsAny(spec, " \t\n\r") {
|
||||||
|
return fmt.Errorf("source spec contains whitespace")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path forms.
|
||||||
|
if strings.HasPrefix(spec, "/") ||
|
||||||
|
strings.HasPrefix(spec, "./") ||
|
||||||
|
strings.HasPrefix(spec, "../") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// URL forms.
|
||||||
|
if strings.HasPrefix(spec, "https://") || strings.HasPrefix(spec, "http://") {
|
||||||
|
return validateURLSpec(spec)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Channel-or-version (with optional leading colon).
|
||||||
|
chanPart := strings.TrimPrefix(spec, ":")
|
||||||
|
if chanPart == "" {
|
||||||
|
return fmt.Errorf("empty channel after ':'")
|
||||||
|
}
|
||||||
|
return validateChannelOrVersion(chanPart)
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateURLSpec checks the URL-prefix or full-URL form. Splits on the
|
||||||
|
// last `:` after the last `/` (matching apps.parseURLSpec behavior).
|
||||||
|
func validateURLSpec(spec string) error {
|
||||||
|
// Minimal sanity check on URL shape.
|
||||||
|
if len(spec) <= len("https://") {
|
||||||
|
return fmt.Errorf("URL is missing host")
|
||||||
|
}
|
||||||
|
lastSlash := strings.LastIndex(spec, "/")
|
||||||
|
if lastSlash < 0 {
|
||||||
|
return fmt.Errorf("invalid URL %q: missing path separator", spec)
|
||||||
|
}
|
||||||
|
afterSlash := spec[lastSlash+1:]
|
||||||
|
colonInTail := strings.LastIndex(afterSlash, ":")
|
||||||
|
urlPart, suffixPart := spec, ""
|
||||||
|
if colonInTail >= 0 {
|
||||||
|
urlPart = spec[:lastSlash+1+colonInTail]
|
||||||
|
suffixPart = afterSlash[colonInTail+1:]
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(urlPart, ".html") {
|
||||||
|
if suffixPart != "" {
|
||||||
|
return fmt.Errorf("URL ends in .html but has %q suffix", ":"+suffixPart)
|
||||||
|
}
|
||||||
|
return nil // terminal full URL
|
||||||
|
}
|
||||||
|
if suffixPart != "" {
|
||||||
|
return validateChannelOrVersion(suffixPart)
|
||||||
|
}
|
||||||
|
return nil // URL-prefix only
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateChannelOrVersion enforces the channel/version shape.
|
||||||
|
func validateChannelOrVersion(s string) error {
|
||||||
|
if s == "stable" || s == "beta" || s == "alpha" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rest := strings.TrimPrefix(s, "v")
|
||||||
|
if rest == "" {
|
||||||
|
return fmt.Errorf("unrecognized source spec %q", s)
|
||||||
|
}
|
||||||
|
parts := strings.Split(rest, ".")
|
||||||
|
if len(parts) > 3 {
|
||||||
|
return fmt.Errorf("version has too many dots: %q", s)
|
||||||
|
}
|
||||||
|
for _, p := range parts {
|
||||||
|
if p == "" {
|
||||||
|
return fmt.Errorf("version has empty component: %q", s)
|
||||||
|
}
|
||||||
|
for _, r := range p {
|
||||||
|
if r < '0' || r > '9' {
|
||||||
|
return fmt.Errorf("unrecognized source spec %q", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func ValidateFile(zf ZddcFile) []FieldError {
|
func ValidateFile(zf ZddcFile) []FieldError {
|
||||||
var errs []FieldError
|
var errs []FieldError
|
||||||
check := func(field string, vals []string) {
|
check := func(field string, vals []string) {
|
||||||
|
|
@ -107,5 +228,20 @@ func ValidateFile(zf ZddcFile) []FieldError {
|
||||||
Message: "title exceeds 200 characters",
|
Message: "title exceeds 200 characters",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
for app, spec := range zf.Apps {
|
||||||
|
if !IsValidAppsKey(app) {
|
||||||
|
errs = append(errs, FieldError{
|
||||||
|
Field: fmt.Sprintf("apps.%s", app),
|
||||||
|
Message: fmt.Sprintf("unknown app %q (known: default, archive, transmittal, classifier, mdedit, landing)", app),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := ValidateAppSourceSpec(spec); err != nil {
|
||||||
|
errs = append(errs, FieldError{
|
||||||
|
Field: fmt.Sprintf("apps.%s", app),
|
||||||
|
Message: err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
return errs
|
return errs
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
package zddc
|
package zddc
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
func TestValidatePattern(t *testing.T) {
|
func TestValidatePattern(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
|
|
@ -64,6 +66,125 @@ func TestValidateFile(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestValidateAppSourceSpec(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
spec string
|
||||||
|
ok bool
|
||||||
|
}{
|
||||||
|
// Channel shorthand (with and without leading colon)
|
||||||
|
{"stable", true},
|
||||||
|
{"beta", true},
|
||||||
|
{"alpha", true},
|
||||||
|
{":stable", true},
|
||||||
|
{":beta", true},
|
||||||
|
{":alpha", true},
|
||||||
|
// Version pin shorthand (full, partial, with/without leading 'v')
|
||||||
|
{"v0.0.4", true},
|
||||||
|
{"0.0.4", true},
|
||||||
|
{"v0.0", true},
|
||||||
|
{"0.0", true},
|
||||||
|
{"v0", true},
|
||||||
|
{"0", true},
|
||||||
|
{"v1.2.3", true},
|
||||||
|
{":v0.0.4", true},
|
||||||
|
{":0.0.4", true},
|
||||||
|
// URLs
|
||||||
|
{"https://zddc.varasys.io/releases/archive_stable.html", true},
|
||||||
|
{"http://my-fork.example.com/archive.html", true},
|
||||||
|
{"https://my-mirror.example/releases", true}, // URL-prefix only
|
||||||
|
{"https://my-mirror.example/releases:stable", true}, // URL-prefix + channel
|
||||||
|
{"https://my-mirror.example/releases:v0.0.4", true}, // URL-prefix + version
|
||||||
|
{"https://my-mirror.example:8080/releases", true}, // URL with port
|
||||||
|
{"https://my-mirror.example:8080/releases:stable", true}, // URL with port + channel
|
||||||
|
// Paths
|
||||||
|
{"/abs/path.html", true},
|
||||||
|
{"./local.html", true},
|
||||||
|
{"../sibling.html", true},
|
||||||
|
// Errors
|
||||||
|
{"", false},
|
||||||
|
{" stable", false},
|
||||||
|
{"stable ", false},
|
||||||
|
{"with space", false},
|
||||||
|
{"https://", false},
|
||||||
|
{"https://host/path/file.html:stable", false}, // .html URL with suffix
|
||||||
|
{"random-thing", false},
|
||||||
|
{":", false},
|
||||||
|
{":random", false},
|
||||||
|
{"v", false},
|
||||||
|
{"v0.", false},
|
||||||
|
{".0.0", false},
|
||||||
|
{"v0.0.0.0", false},
|
||||||
|
{"v0.a.0", false},
|
||||||
|
{"https://my-mirror.example/releases:bogus", false}, // bad channel suffix
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.spec, func(t *testing.T) {
|
||||||
|
err := ValidateAppSourceSpec(tc.spec)
|
||||||
|
if tc.ok && err != nil {
|
||||||
|
t.Errorf("ValidateAppSourceSpec(%q) = %v, want nil", tc.spec, err)
|
||||||
|
}
|
||||||
|
if !tc.ok && err == nil {
|
||||||
|
t.Errorf("ValidateAppSourceSpec(%q) = nil, want error", tc.spec)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsValidAppsKey(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
key string
|
||||||
|
ok bool
|
||||||
|
}{
|
||||||
|
{"default", true},
|
||||||
|
{"archive", true},
|
||||||
|
{"transmittal", true},
|
||||||
|
{"classifier", true},
|
||||||
|
{"mdedit", true},
|
||||||
|
{"landing", true},
|
||||||
|
{"unknown", false},
|
||||||
|
{"", false},
|
||||||
|
{"DEFAULT", false}, // case-sensitive
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.key, func(t *testing.T) {
|
||||||
|
if got := IsValidAppsKey(tc.key); got != tc.ok {
|
||||||
|
t.Errorf("IsValidAppsKey(%q) = %v, want %v", tc.key, got, tc.ok)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateFile_Apps(t *testing.T) {
|
||||||
|
zf := ZddcFile{
|
||||||
|
Apps: map[string]string{
|
||||||
|
"archive": "stable", // ok
|
||||||
|
"classifier": "v0.0.4", // ok
|
||||||
|
"default": "https://zddc.varasys.io/releases:stable", // ok (default key + URL+channel)
|
||||||
|
"transmittal": ":beta", // ok (channel-only)
|
||||||
|
"mdedit": "https://my-mirror.example/releases", // ok (URL-prefix only)
|
||||||
|
"unknown": "stable", // unknown app
|
||||||
|
"landing": "what is this", // bad spec
|
||||||
|
},
|
||||||
|
}
|
||||||
|
errs := ValidateFile(zf)
|
||||||
|
want := map[string]bool{
|
||||||
|
"apps.unknown": false,
|
||||||
|
"apps.landing": false,
|
||||||
|
}
|
||||||
|
for _, e := range errs {
|
||||||
|
if _, ok := want[e.Field]; ok {
|
||||||
|
want[e.Field] = true
|
||||||
|
} else {
|
||||||
|
t.Errorf("unexpected error field: %q (%s)", e.Field, e.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for f, seen := range want {
|
||||||
|
if !seen {
|
||||||
|
t.Errorf("missing error for field %q (got: %+v)", f, errs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestValidateProjectName(t *testing.T) {
|
func TestValidateProjectName(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
name string
|
name string
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue