socle

config loading: strict YAML, validated names, the no-prefix rule

commit 538475798248c9a4f897a199f7e1dd0157e18ce7 · parent 2fcf91c586 · browse this tree
Ximianth, 2026-10-26 19:43:44 UTC

Follows vitrine's config idiom: required -config flag territory, unknown
keys are errors, relative paths resolve against the config file's dir,
and the % placeholder means the path matches the repo name.

Two rules beyond name validation, both serving unambiguous URL dispatch:
no repo name may be a path-prefix of another, and no name may end in
.git (a trailing .git in a URL is an alias for the plain name).

Whether configured directories exist is deliberately not checked here:
one config may describe repos only some hosts have, so absence is a
warning at open time, not a load error.

Tests are testmark fixtures in testdata/config.md, regenerable with
-testmark.regen; scratch dirs appear as $BASE/$PARENT so results are
location-independent.
diff --git a/config.go b/config.go
new file mode 100644
index 0000000..11e9040
--- /dev/null
+++ b/config.go
@@ -0,0 +1,108 @@
+package main
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+	"sort"
+	"strings"
+
+	"gopkg.in/yaml.v3"
+)
+
+// repoConfig is one configured repository: a directory served under a URL name.
+type repoConfig struct {
+	// Path is the repository directory: a worktree checkout (with a .git inside) or a bare repo,
+	// either is fine.  In the config file it may be relative (resolved against the config file's
+	// directory) or the placeholder "%" (meaning: same as the repo name);
+	// after loadConfig it is always absolute.
+	Path string `yaml:"path"`
+	// Description is an optional one-liner shown in listings.
+	Description string `yaml:"description"`
+}
+
+// config is the top-level schema of the socle config file.
+type config struct {
+	// Repos maps URL names (which may contain slashes) to the repositories they serve.
+	Repos map[string]repoConfig `yaml:"repos"`
+}
+
+// loadConfig reads and validates the YAML config file at the given path.
+// Relative repo paths are resolved against the config file's directory,
+// so a config travels with the tree it describes.
+// Any parse or validation problem is an error: socle refuses to start on a bad config.
+// (Whether the configured directories actually exist is deliberately not checked here;
+// that's openAll's business, and absence there is a warning, not an error.)
+func loadConfig(path string) (*config, error) {
+	f, err := os.Open(path)
+	if err != nil {
+		return nil, fmt.Errorf("config: %w", err)
+	}
+	defer f.Close()
+
+	var cfg config
+	dec := yaml.NewDecoder(f)
+	dec.KnownFields(true) // typo'd keys are errors, not silence
+	if err := dec.Decode(&cfg); err != nil {
+		return nil, fmt.Errorf("config %s: %w", path, err)
+	}
+
+	if len(cfg.Repos) == 0 {
+		return nil, fmt.Errorf("config %s: no repos configured; a socle with nothing on display is a misconfiguration", path)
+	}
+
+	base, err := filepath.Abs(filepath.Dir(path))
+	if err != nil {
+		return nil, fmt.Errorf("config %s: resolving directory: %w", path, err)
+	}
+	names := sortedNames(cfg.Repos)
+	for _, name := range names {
+		rc := cfg.Repos[name]
+		if err := validateRepoName(name); err != nil {
+			return nil, fmt.Errorf("config %s: %w", path, err)
+		}
+		// The path "%" is a placeholder meaning "same as the repo name":
+		// the near-universal case where the URL layout mirrors the directory layout
+		// relative to the config file.
+		// (Explicit placeholder rather than an omittable field:
+		// a bare key with no value reads like an accident in yaml.)
+		if rc.Path == "%" {
+			rc.Path = name
+		}
+		if rc.Path == "" {
+			return nil, fmt.Errorf("config %s: repo %q: missing required field %q (use %q to serve the path matching the repo name)", path, name, "path", "path: %")
+		}
+		rc.Path = strings.TrimRight(rc.Path, "/")
+		if rc.Path == "" {
+			// Only "/" (or "///"...) trims to nothing: serving the root filesystem
+			// is a misconfiguration, not a wish we should guess at.
+			return nil, fmt.Errorf("config %s: repo %q: path %q is the filesystem root; refusing", path, name, "/")
+		}
+		if !filepath.IsAbs(rc.Path) {
+			rc.Path = filepath.Join(base, rc.Path)
+		}
+		cfg.Repos[name] = rc
+	}
+	// No repo name may be a path-prefix of another: URL dispatch does longest-prefix
+	// matching against these names, and nesting would make "foo/bar" ambiguous between
+	// the repo "foo/bar" and the path "bar" within the repo "foo".
+	for _, a := range names {
+		for _, b := range names {
+			if a != b && strings.HasPrefix(b, a+"/") {
+				return nil, fmt.Errorf("config %s: repo %q is a path-prefix of repo %q; nested names would make URL dispatch ambiguous", path, a, b)
+			}
+		}
+	}
+	return &cfg, nil
+}
+
+// sortedNames returns the repo names in byte order:
+// every iteration in this codebase happens in this order, so output and errors are deterministic.
+func sortedNames[V any](m map[string]V) []string {
+	names := make([]string, 0, len(m))
+	for name := range m {
+		names = append(names, name)
+	}
+	sort.Strings(names)
+	return names
+}
diff --git a/config_test.go b/config_test.go
new file mode 100644
index 0000000..7255cfd
--- /dev/null
+++ b/config_test.go
@@ -0,0 +1,78 @@
+package main
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	"github.com/warpfork/go-testmark"
+)
+
+// TestLoadConfig drives loadConfig through the fixture cases in testdata/config.md.
+// Each case is a pair of hunks: <case>/config.yaml (the input) and <case>/result
+// (the expected outcome, either a repo listing or an error line).
+// Run with -testmark.regen to regenerate the result hunks and review via git diff.
+func TestLoadConfig(t *testing.T) {
+	doc, err := testmark.ReadFile("testdata/config.md")
+	if err != nil {
+		t.Fatalf("reading fixtures: %v", err)
+	}
+	doc.BuildDirIndex()
+	var pa testmark.PatchAccumulator
+	for _, dirent := range doc.DirEnt.ChildrenList {
+		t.Run(dirent.Name, func(t *testing.T) {
+			cfgEnt := dirent.Children["config.yaml"]
+			resEnt := dirent.Children["result"]
+			if cfgEnt == nil || cfgEnt.Hunk == nil || resEnt == nil || resEnt.Hunk == nil {
+				t.Fatalf("fixture %q needs both a config.yaml and a result hunk", dirent.Name)
+			}
+			// The config sits in a subdir of the scratch space, so a "../" relative
+			// path in a fixture resolves inside scratch too ($PARENT in results).
+			dir := filepath.Join(t.TempDir(), "etc")
+			if err := os.MkdirAll(dir, 0o755); err != nil {
+				t.Fatal(err)
+			}
+			cfgPath := filepath.Join(dir, "config.yaml")
+			if err := os.WriteFile(cfgPath, cfgEnt.Hunk.Body, 0o644); err != nil {
+				t.Fatal(err)
+			}
+			got := describeLoad(cfgPath, dir)
+			if *testmark.Regen {
+				pa.AppendPatchIfBodyDiffers(*resEnt.Hunk, []byte(got))
+			} else if got != string(resEnt.Hunk.Body) {
+				t.Errorf("result mismatch:\n--- got:\n%s--- want:\n%s", got, resEnt.Hunk.Body)
+			}
+		})
+	}
+	if *testmark.Regen {
+		if err := pa.WriteFileWithPatches(doc, "testdata/config.md"); err != nil {
+			t.Fatal(err)
+		}
+	}
+}
+
+// describeLoad renders a loadConfig outcome as the stable text the fixtures assert on.
+// The scratch directory the config lives in is rewritten to "$BASE" (and its parent,
+// reachable via "../" paths, to "$PARENT"), so the fixtures stay location-independent.
+func describeLoad(cfgPath, base string) string {
+	anonymize := func(s string) string {
+		s = strings.ReplaceAll(s, base, "$BASE")
+		return strings.ReplaceAll(s, filepath.Dir(base), "$PARENT")
+	}
+	cfg, err := loadConfig(cfgPath)
+	if err != nil {
+		return "error: " + anonymize(err.Error()) + "\n"
+	}
+	var sb strings.Builder
+	for _, name := range sortedNames(cfg.Repos) {
+		rc := cfg.Repos[name]
+		fmt.Fprintf(&sb, "repo %s: path=%s", name, anonymize(rc.Path))
+		if rc.Description != "" {
+			fmt.Fprintf(&sb, " description=%q", rc.Description)
+		}
+		sb.WriteString("\n")
+	}
+	return sb.String()
+}
diff --git a/go.mod b/go.mod
index 878573e..50e735a 100644
--- a/go.mod
+++ b/go.mod
@@ -3,6 +3,7 @@ module socle
 go 1.26.5
 
 require (
+	github.com/warpfork/go-testmark v0.12.1
 	gopkg.in/yaml.v3 v3.0.1
 	vitrine v0.0.0
 )
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..b59f50c
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,6 @@
+github.com/warpfork/go-fsx v0.3.0/go.mod h1:oTACCMj+Zle+vgVa5SAhGAh7WksYpLgGUCKEAVc+xPg=
+github.com/warpfork/go-testmark v0.12.1 h1:rMgCpJfwy1sJ50x0M0NgyphxYYPMOODIJHhsXyEHU0s=
+github.com/warpfork/go-testmark v0.12.1/go.mod h1:kHwy7wfvGSPh1rQJYKayD4AbtNaeyZdcGi9tNJTaa5Y=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/testdata/config.md b/testdata/config.md
new file mode 100644
index 0000000..9210cfc
--- /dev/null
+++ b/testdata/config.md
@@ -0,0 +1,168 @@
+config loading fixtures
+=======================
+
+Each case pairs a `config.yaml` input with the `result` of loading it:
+either the normalized repo listing, or the startup error.
+Paths are shown with the config file's directory as `$BASE`.
+Regenerate the result hunks with `go test -run TestLoadConfig . -testmark.regen`.
+
+
+Good configs
+------------
+
+The minimal config: one repo, and the `%` placeholder saying its path matches its name.
+
+[testmark]:# (good-minimal/config.yaml)
+```yaml
+repos:
+  parnass:
+    path: "%"
+```
+
+[testmark]:# (good-minimal/result)
+```text
+repo parnass: path=$BASE/parnass
+```
+
+Names may be slash-nested to arbitrary depth; paths may be relative (resolved against
+the config file's directory, even pointing outside it) or absolute; descriptions are optional.
+
+[testmark]:# (good-varied/config.yaml)
+```yaml
+repos:
+  parnass:
+    path: "%"
+    description: "the showcase repo"
+  work/infra/deploy:
+    path: ../deploy
+  elsewhere:
+    path: /srv/git/elsewhere
+```
+
+[testmark]:# (good-varied/result)
+```text
+repo elsewhere: path=/srv/git/elsewhere
+repo parnass: path=$BASE/parnass description="the showcase repo"
+repo work/infra/deploy: path=$PARENT/deploy
+```
+
+Trailing slashes on paths are normalized away.
+
+[testmark]:# (good-trailing-slash-path/config.yaml)
+```yaml
+repos:
+  tidy:
+    path: "sub/dir/"
+```
+
+[testmark]:# (good-trailing-slash-path/result)
+```text
+repo tidy: path=$BASE/sub/dir
+```
+
+
+Bad configs
+-----------
+
+Every problem below is a startup error: socle refuses to run on a config it can't fully honor.
+
+Unknown keys are errors, not silence; typos should not quietly change meaning.
+
+[testmark]:# (err-unknown-key/config.yaml)
+```yaml
+repos:
+  parnass:
+    path: "%"
+    exposed: true
+```
+
+[testmark]:# (err-unknown-key/result)
+```text
+error: config $BASE/config.yaml: yaml: unmarshal errors:
+  line 4: field exposed not found in type main.repoConfig
+```
+
+A config with nothing to serve is a misconfiguration.
+
+[testmark]:# (err-no-repos/config.yaml)
+```yaml
+repos: {}
+```
+
+[testmark]:# (err-no-repos/result)
+```text
+error: config $BASE/config.yaml: no repos configured; a socle with nothing on display is a misconfiguration
+```
+
+Repo names are validated: segments of `[A-Za-z0-9._-]`, no leading dot or dash.
+
+[testmark]:# (err-bad-name/config.yaml)
+```yaml
+repos:
+  .hidden:
+    path: "%"
+```
+
+[testmark]:# (err-bad-name/result)
+```text
+error: config $BASE/config.yaml: invalid: repo name ".hidden": segment ".hidden" starts with "."
+```
+
+Names ending in `.git` are rejected: URLs treat a trailing `.git` as an alias
+for the plain name, and configuring one would make that alias ambiguous.
+
+[testmark]:# (err-git-suffix-name/config.yaml)
+```yaml
+repos:
+  parnass.git:
+    path: "%"
+```
+
+[testmark]:# (err-git-suffix-name/result)
+```text
+error: config $BASE/config.yaml: invalid: repo name "parnass.git": segment "parnass.git" ends in .git (reserved as a URL alias for the plain name)
+```
+
+No repo name may be a path-prefix of another; nested names would make URL dispatch ambiguous.
+
+[testmark]:# (err-prefix-collision/config.yaml)
+```yaml
+repos:
+  work:
+    path: "%"
+  work/deploy:
+    path: "%"
+```
+
+[testmark]:# (err-prefix-collision/result)
+```text
+error: config $BASE/config.yaml: repo "work" is a path-prefix of repo "work/deploy"; nested names would make URL dispatch ambiguous
+```
+
+The path field is required; an empty entry is probably an accident, so there is no default.
+
+[testmark]:# (err-missing-path/config.yaml)
+```yaml
+repos:
+  parnass:
+    description: "all description, no substance"
+```
+
+[testmark]:# (err-missing-path/result)
+```text
+error: config $BASE/config.yaml: repo "parnass": missing required field "path" (use "path: %" to serve the path matching the repo name)
+```
+
+Serving the filesystem root is refused outright.
+
+[testmark]:# (err-root-path/config.yaml)
+```yaml
+repos:
+  everything:
+    path: "/"
+```
+
+[testmark]:# (err-root-path/result)
+```text
+error: config $BASE/config.yaml: repo "everything": path "/" is the filesystem root; refusing
+```