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 +```