config.go
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
}
config.go · 3.8 KiB · 370d8f1c9f