config: path shorthand, and trailing-slash normalization
commit ca414d4fd8eb426648ef9565b988444727641667 · parent 3d0af8c57e
· browse this tree
Ximianth, 2026-07-30 10:59:02 UTC
Two field-experience tweaks: An omitted path now means 'same as the exhibit name' -- in practice nearly all exhibits mirror the directory layout relative to the config file, so the common config shrinks to bare names. Trailing slashes on exhibit names and paths are accepted and trimmed. A slash-suffixed NAME was the real hazard: dispatch matched the directory listing but 404'd every file within (reproduced before fixing). A slash-suffixed path was harmless (os.OpenRoot tolerates it, and already errors on non-directories, so no separate dir check is needed). Trimming can collide two spellings of one name, or reduce '/' to nothing; both are config errors now. The live config on this host is rewritten in shorthand form.
diff --git a/README.md b/README.md index fc1d7a6..a3478a2 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,6 @@ The format is YAML: ```yaml exhibits: moonshot-1: - path: moonshot-1 meta/research/some-topic: path: ../elsewhere/some-topic some-repo: @@ -29,13 +28,16 @@ exhibits: - `exhibits` maps URL prefixes to directories. A prefix may contain slashes; the intermediate URL levels then serve synthetic listings that reveal only the mounted subtrees. -- `path` (required) is the directory to serve. +- `path` (optional) is the directory to serve. + When omitted, it defaults to the exhibit's own name -- + the common case where the URL space mirrors the directory layout. Relative paths resolve against the config file's directory, which makes a config portable alongside the trees it describes. Absolute paths also work. + Trailing slashes on names and paths are accepted and normalized away. - `expose-git` (optional, default false): see below. -A bad config (parse error, unknown key, missing path, or zero exhibits) +A bad config (parse error, unknown key, or zero exhibits) is a startup error: vitrine refuses to run rather than serve the wrong thing. Config changes apply live: vitrine watches the config file diff --git a/config.go b/config.go index 335c660..7301c2b 100644 --- a/config.go +++ b/config.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "gopkg.in/yaml.v3" ) @@ -11,7 +12,8 @@ import ( // exhibit is one configured mount: a directory served under a URL prefix. type exhibit struct { // Path is the directory to serve. - // In the config file it may be relative (resolved against the config file's directory); + // In the config file it may be relative (resolved against the config file's directory), + // or omitted entirely, which means "same as the exhibit name"; // after loadConfig it is always absolute. Path string `yaml:"path"` // ExposeGit makes the .git directory list and serve like any other content @@ -51,14 +53,37 @@ func loadConfig(path string) (*config, error) { if err != nil { return nil, fmt.Errorf("config %s: resolving directory: %w", path, err) } + normalized := make(map[string]exhibit, len(cfg.Exhibits)) for name, ex := range cfg.Exhibits { + // Trailing slashes on names and paths are accepted and normalized away. + // A slash-suffixed name would otherwise silently break dispatch + // (the listing works but every file inside 404s), + // and a slash-suffixed path is harmless but inconsistent. + // Directory-ness is not lost as a check: openRoot fails on non-directories anyway. + cleanName := strings.TrimRight(name, "/") + if cleanName == "" { + return nil, fmt.Errorf("config %s: exhibit %q: name is empty after trimming slashes", path, name) + } + if _, dup := normalized[cleanName]; dup { + return nil, fmt.Errorf("config %s: exhibit %q: duplicate of %q after trimming slashes", path, name, cleanName) + } + // An omitted path means "same as the exhibit name": + // the near-universal case where the URL prefix mirrors the directory layout + // relative to the config file. + if ex.Path == "" { + ex.Path = cleanName + } + ex.Path = strings.TrimRight(ex.Path, "/") if ex.Path == "" { - return nil, fmt.Errorf("config %s: exhibit %q: missing required field %q", path, name, "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: exhibit %q: path %q is the filesystem root; refusing", path, name, "/") } if !filepath.IsAbs(ex.Path) { ex.Path = filepath.Join(base, ex.Path) - cfg.Exhibits[name] = ex } + normalized[cleanName] = ex } + cfg.Exhibits = normalized return &cfg, nil } diff --git a/config_test.go b/config_test.go index aa3c7f6..b748803 100644 --- a/config_test.go +++ b/config_test.go @@ -71,15 +71,65 @@ exhibits: } } -func TestConfigMissingPathRejected(t *testing.T) { +func TestConfigOmittedPathMeansExhibitName(t *testing.T) { p := writeConfig(t, ` exhibits: - demo: + meta/research/thing: expose-git: true +`) + cfg, err := loadConfig(p) + if err != nil { + t.Fatal(err) + } + want := filepath.Join(filepath.Dir(p), "meta/research/thing") + if got := cfg.Exhibits["meta/research/thing"]; got.Path != want || !got.ExposeGit { + t.Errorf("exhibit = %+v, want path %q", got, want) + } +} + +func TestConfigTrailingSlashesNormalized(t *testing.T) { + p := writeConfig(t, ` +exhibits: + demo/: + path: /srv/demo/ + shorthand/: +`) + cfg, err := loadConfig(p) + if err != nil { + t.Fatal(err) + } + if got, ok := cfg.Exhibits["demo"]; !ok || got.Path != "/srv/demo" { + t.Errorf("demo = %+v, ok=%v; want slash-trimmed name and path", got, ok) + } + want := filepath.Join(filepath.Dir(p), "shorthand") + if got, ok := cfg.Exhibits["shorthand"]; !ok || got.Path != want { + t.Errorf("shorthand = %+v, ok=%v; want path %q", got, ok, want) + } +} + +func TestConfigSlashCollisionsRejected(t *testing.T) { + p := writeConfig(t, ` +exhibits: + demo: + path: /srv/demo + demo/: + path: /srv/other +`) + _, err := loadConfig(p) + if err == nil || !strings.Contains(err.Error(), "duplicate") { + t.Fatalf("want duplicate error, got %v", err) + } +} + +func TestConfigRootPathRejected(t *testing.T) { + p := writeConfig(t, ` +exhibits: + demo: + path: / `) _, err := loadConfig(p) - if err == nil || !strings.Contains(err.Error(), "path") { - t.Fatalf("want missing-path error, got %v", err) + if err == nil || !strings.Contains(err.Error(), "root") { + t.Fatalf("want filesystem-root error, got %v", err) } }