detached aa54e60

feat(config): parse and validate the server environment

commit
aa54e60cf08e11c71c5cd2c63a53a920044da3a9
author
brice · · 2026-10-10 06:16 UTC
parent
9db7dbf
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +389 -0
A internal/config/config.go +231 -0
@@ -0,0 +1,231 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+// Package config parses and validates the server configuration from the
4+// environment. It is an entrypoint helper: only cmd imports it, and it never
5+// reads the process environment itself, the caller passes a lookup function.
6+package config
7+
8+import (
9+ "crypto/sha256"
10+ "fmt"
11+ "net"
12+ "net/netip"
13+ "net/url"
14+ "path/filepath"
15+ "strconv"
16+ "strings"
17+ "unicode/utf8"
18+)
19+
20+// Variable names.
21+const (
22+ EnvAddr = "ERMITE_ADDR"
23+ EnvDataDir = "ERMITE_DATA_DIR"
24+ EnvPublicURL = "ERMITE_PUBLIC_URL"
25+ EnvAdminToken = "ERMITE_ADMIN_TOKEN"
26+ EnvTrustedProxies = "ERMITE_TRUSTED_PROXIES"
27+ EnvGit = "ERMITE_GIT"
28+ EnvSourceURL = "ERMITE_SOURCE_URL"
29+ EnvLogLevel = "ERMITE_LOG_LEVEL"
30+ EnvLogFormat = "ERMITE_LOG_FORMAT"
31+)
32+
33+// Defaults.
34+const (
35+ DefaultAddr = ":7341"
36+ DefaultGit = "git"
37+ DefaultLogLevel = "info"
38+ DefaultLogFormat = "json"
39+)
40+
41+// MinAdminTokenLen is the shortest accepted admin token, in characters.
42+const MinAdminTokenLen = 32
43+
44+// Config is the validated server configuration.
45+type Config struct {
46+ // Addr is the listen address, "host:port".
47+ Addr string
48+ // DataDir is the absolute, clean data root.
49+ DataDir string
50+ // PublicURL is the base URL without a trailing slash.
51+ PublicURL string
52+ // AdminTokenHash is the SHA-256 of the admin token. The token itself is
53+ // dropped; compare with a constant-time function.
54+ AdminTokenHash [sha256.Size]byte
55+ // TrustedProxies are the networks allowed to set X-Forwarded-For.
56+ TrustedProxies []netip.Prefix
57+ // Git is the git executable, a name or a path.
58+ Git string
59+ // SourceURL is the link to the source of the running version.
60+ SourceURL string
61+ // LogLevel is one of trace, debug, info, warn, error.
62+ LogLevel string
63+ // LogFormat is json or console.
64+ LogFormat string
65+}
66+
67+// Error lists every configuration problem found, in variable order.
68+type Error struct {
69+ Problems []string
70+}
71+
72+// Error implements error.
73+func (e *Error) Error() string {
74+ var b strings.Builder
75+ b.WriteString("invalid configuration:")
76+ for _, p := range e.Problems {
77+ b.WriteString("\n - ")
78+ b.WriteString(p)
79+ }
80+ return b.String()
81+}
82+
83+// Load reads the configuration through lookup, which returns the value of a
84+// variable and whether it is set (os.LookupEnv fits). An empty value counts as
85+// unset. All problems are reported together in an *Error. Messages name the
86+// variable and never contain the admin token.
87+func Load(lookup func(string) (string, bool)) (Config, error) {
88+ get := func(key string) string {
89+ v, _ := lookup(key)
90+ return v
91+ }
92+ var problems []string
93+ bad := func(key, format string, args ...any) {
94+ problems = append(problems, key+": "+fmt.Sprintf(format, args...))
95+ }
96+ var c Config
97+
98+ c.Addr = get(EnvAddr)
99+ if c.Addr == "" {
100+ c.Addr = DefaultAddr
101+ } else if err := validateAddr(c.Addr); err != nil {
102+ bad(EnvAddr, "%v", err)
103+ }
104+
105+ switch dir := get(EnvDataDir); {
106+ case dir == "":
107+ bad(EnvDataDir, "required")
108+ case !filepath.IsAbs(dir):
109+ bad(EnvDataDir, "must be an absolute path")
110+ default:
111+ c.DataDir = filepath.Clean(dir)
112+ }
113+
114+ publicOK := false
115+ if raw := get(EnvPublicURL); raw == "" {
116+ bad(EnvPublicURL, "required")
117+ } else if u, err := parseURL(raw, false); err != nil {
118+ bad(EnvPublicURL, "%v", err)
119+ } else {
120+ c.PublicURL = u
121+ publicOK = true
122+ }
123+
124+ if token := get(EnvAdminToken); token == "" {
125+ bad(EnvAdminToken, "required")
126+ } else if n := utf8.RuneCountInString(token); n < MinAdminTokenLen {
127+ bad(EnvAdminToken, "must be at least %d characters (got %d)", MinAdminTokenLen, n)
128+ } else {
129+ c.AdminTokenHash = sha256.Sum256([]byte(token))
130+ }
131+
132+ if raw := get(EnvTrustedProxies); raw != "" {
133+ prefixes, err := parseCIDRs(raw)
134+ if err != nil {
135+ bad(EnvTrustedProxies, "%v", err)
136+ }
137+ c.TrustedProxies = prefixes
138+ }
139+
140+ c.Git = get(EnvGit)
141+ if c.Git == "" {
142+ c.Git = DefaultGit
143+ }
144+
145+ switch raw := get(EnvSourceURL); {
146+ case raw != "":
147+ if u, err := parseURL(raw, true); err != nil {
148+ bad(EnvSourceURL, "%v", err)
149+ } else {
150+ c.SourceURL = u
151+ }
152+ case publicOK:
153+ c.SourceURL = c.PublicURL + "/ermite"
154+ }
155+
156+ c.LogLevel = strings.ToLower(get(EnvLogLevel))
157+ switch c.LogLevel {
158+ case "":
159+ c.LogLevel = DefaultLogLevel
160+ case "trace", "debug", "info", "warn", "error":
161+ default:
162+ bad(EnvLogLevel, "must be one of trace, debug, info, warn, error (got %q)", get(EnvLogLevel))
163+ }
164+
165+ c.LogFormat = strings.ToLower(get(EnvLogFormat))
166+ switch c.LogFormat {
167+ case "":
168+ c.LogFormat = DefaultLogFormat
169+ case "json", "console":
170+ default:
171+ bad(EnvLogFormat, "must be json or console (got %q)", get(EnvLogFormat))
172+ }
173+
174+ if len(problems) > 0 {
175+ return Config{}, &Error{Problems: problems}
176+ }
177+ return c, nil
178+}
179+
180+func validateAddr(addr string) error {
181+ _, port, err := net.SplitHostPort(addr)
182+ if err != nil {
183+ return fmt.Errorf("must be host:port, such as %q (%v)", DefaultAddr, err)
184+ }
185+ n, err := strconv.Atoi(port)
186+ if err != nil || n < 1 || n > 65535 {
187+ return fmt.Errorf("port must be a number from 1 to 65535 (got %q)", port)
188+ }
189+ return nil
190+}
191+
192+// parseURL accepts an http(s) URL with a host and no credentials, query or
193+// fragment, and returns it without a trailing slash. A path is accepted only
194+// with allowPath.
195+func parseURL(raw string, allowPath bool) (string, error) {
196+ u, err := url.Parse(raw)
197+ if err != nil {
198+ return "", fmt.Errorf("not a valid URL: %v", err)
199+ }
200+ switch {
201+ case u.Scheme != "http" && u.Scheme != "https":
202+ return "", fmt.Errorf("scheme must be http or https (got %q)", u.Scheme)
203+ case u.Host == "" || u.Hostname() == "":
204+ return "", fmt.Errorf("host is missing")
205+ case u.User != nil:
206+ return "", fmt.Errorf("must not contain credentials")
207+ case !allowPath && u.Path != "" && u.Path != "/":
208+ return "", fmt.Errorf("must not contain a path (got %q)", u.Path)
209+ case u.RawQuery != "" || u.ForceQuery:
210+ return "", fmt.Errorf("must not contain a query")
211+ case u.Fragment != "" || strings.Contains(raw, "#"):
212+ return "", fmt.Errorf("must not contain a fragment")
213+ }
214+ return strings.TrimSuffix(u.String(), "/"), nil
215+}
216+
217+func parseCIDRs(raw string) ([]netip.Prefix, error) {
218+ var out []netip.Prefix
219+ for part := range strings.SplitSeq(raw, ",") {
220+ part = strings.TrimSpace(part)
221+ if part == "" {
222+ return nil, fmt.Errorf("empty entry in the comma-separated list")
223+ }
224+ p, err := netip.ParsePrefix(part)
225+ if err != nil {
226+ return nil, fmt.Errorf("%q is not a CIDR such as 10.0.0.0/8 or fd00::/8", part)
227+ }
228+ out = append(out, p.Masked())
229+ }
230+ return out, nil
231+}
@@ -0,0 +1,231 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+// Package config parses and validates the server configuration from the
4+// environment. It is an entrypoint helper: only cmd imports it, and it never
5+// reads the process environment itself, the caller passes a lookup function.
6+package config
7+
8+import (
9+ "crypto/sha256"
10+ "fmt"
11+ "net"
12+ "net/netip"
13+ "net/url"
14+ "path/filepath"
15+ "strconv"
16+ "strings"
17+ "unicode/utf8"
18+)
19+
20+// Variable names.
21+const (
22+ EnvAddr = "ERMITE_ADDR"
23+ EnvDataDir = "ERMITE_DATA_DIR"
24+ EnvPublicURL = "ERMITE_PUBLIC_URL"
25+ EnvAdminToken = "ERMITE_ADMIN_TOKEN"
26+ EnvTrustedProxies = "ERMITE_TRUSTED_PROXIES"
27+ EnvGit = "ERMITE_GIT"
28+ EnvSourceURL = "ERMITE_SOURCE_URL"
29+ EnvLogLevel = "ERMITE_LOG_LEVEL"
30+ EnvLogFormat = "ERMITE_LOG_FORMAT"
31+)
32+
33+// Defaults.
34+const (
35+ DefaultAddr = ":7341"
36+ DefaultGit = "git"
37+ DefaultLogLevel = "info"
38+ DefaultLogFormat = "json"
39+)
40+
41+// MinAdminTokenLen is the shortest accepted admin token, in characters.
42+const MinAdminTokenLen = 32
43+
44+// Config is the validated server configuration.
45+type Config struct {
46+ // Addr is the listen address, "host:port".
47+ Addr string
48+ // DataDir is the absolute, clean data root.
49+ DataDir string
50+ // PublicURL is the base URL without a trailing slash.
51+ PublicURL string
52+ // AdminTokenHash is the SHA-256 of the admin token. The token itself is
53+ // dropped; compare with a constant-time function.
54+ AdminTokenHash [sha256.Size]byte
55+ // TrustedProxies are the networks allowed to set X-Forwarded-For.
56+ TrustedProxies []netip.Prefix
57+ // Git is the git executable, a name or a path.
58+ Git string
59+ // SourceURL is the link to the source of the running version.
60+ SourceURL string
61+ // LogLevel is one of trace, debug, info, warn, error.
62+ LogLevel string
63+ // LogFormat is json or console.
64+ LogFormat string
65+}
66+
67+// Error lists every configuration problem found, in variable order.
68+type Error struct {
69+ Problems []string
70+}
71+
72+// Error implements error.
73+func (e *Error) Error() string {
74+ var b strings.Builder
75+ b.WriteString("invalid configuration:")
76+ for _, p := range e.Problems {
77+ b.WriteString("\n - ")
78+ b.WriteString(p)
79+ }
80+ return b.String()
81+}
82+
83+// Load reads the configuration through lookup, which returns the value of a
84+// variable and whether it is set (os.LookupEnv fits). An empty value counts as
85+// unset. All problems are reported together in an *Error. Messages name the
86+// variable and never contain the admin token.
87+func Load(lookup func(string) (string, bool)) (Config, error) {
88+ get := func(key string) string {
89+ v, _ := lookup(key)
90+ return v
91+ }
92+ var problems []string
93+ bad := func(key, format string, args ...any) {
94+ problems = append(problems, key+": "+fmt.Sprintf(format, args...))
95+ }
96+ var c Config
97+
98+ c.Addr = get(EnvAddr)
99+ if c.Addr == "" {
100+ c.Addr = DefaultAddr
101+ } else if err := validateAddr(c.Addr); err != nil {
102+ bad(EnvAddr, "%v", err)
103+ }
104+
105+ switch dir := get(EnvDataDir); {
106+ case dir == "":
107+ bad(EnvDataDir, "required")
108+ case !filepath.IsAbs(dir):
109+ bad(EnvDataDir, "must be an absolute path")
110+ default:
111+ c.DataDir = filepath.Clean(dir)
112+ }
113+
114+ publicOK := false
115+ if raw := get(EnvPublicURL); raw == "" {
116+ bad(EnvPublicURL, "required")
117+ } else if u, err := parseURL(raw, false); err != nil {
118+ bad(EnvPublicURL, "%v", err)
119+ } else {
120+ c.PublicURL = u
121+ publicOK = true
122+ }
123+
124+ if token := get(EnvAdminToken); token == "" {
125+ bad(EnvAdminToken, "required")
126+ } else if n := utf8.RuneCountInString(token); n < MinAdminTokenLen {
127+ bad(EnvAdminToken, "must be at least %d characters (got %d)", MinAdminTokenLen, n)
128+ } else {
129+ c.AdminTokenHash = sha256.Sum256([]byte(token))
130+ }
131+
132+ if raw := get(EnvTrustedProxies); raw != "" {
133+ prefixes, err := parseCIDRs(raw)
134+ if err != nil {
135+ bad(EnvTrustedProxies, "%v", err)
136+ }
137+ c.TrustedProxies = prefixes
138+ }
139+
140+ c.Git = get(EnvGit)
141+ if c.Git == "" {
142+ c.Git = DefaultGit
143+ }
144+
145+ switch raw := get(EnvSourceURL); {
146+ case raw != "":
147+ if u, err := parseURL(raw, true); err != nil {
148+ bad(EnvSourceURL, "%v", err)
149+ } else {
150+ c.SourceURL = u
151+ }
152+ case publicOK:
153+ c.SourceURL = c.PublicURL + "/ermite"
154+ }
155+
156+ c.LogLevel = strings.ToLower(get(EnvLogLevel))
157+ switch c.LogLevel {
158+ case "":
159+ c.LogLevel = DefaultLogLevel
160+ case "trace", "debug", "info", "warn", "error":
161+ default:
162+ bad(EnvLogLevel, "must be one of trace, debug, info, warn, error (got %q)", get(EnvLogLevel))
163+ }
164+
165+ c.LogFormat = strings.ToLower(get(EnvLogFormat))
166+ switch c.LogFormat {
167+ case "":
168+ c.LogFormat = DefaultLogFormat
169+ case "json", "console":
170+ default:
171+ bad(EnvLogFormat, "must be json or console (got %q)", get(EnvLogFormat))
172+ }
173+
174+ if len(problems) > 0 {
175+ return Config{}, &Error{Problems: problems}
176+ }
177+ return c, nil
178+}
179+
180+func validateAddr(addr string) error {
181+ _, port, err := net.SplitHostPort(addr)
182+ if err != nil {
183+ return fmt.Errorf("must be host:port, such as %q (%v)", DefaultAddr, err)
184+ }
185+ n, err := strconv.Atoi(port)
186+ if err != nil || n < 1 || n > 65535 {
187+ return fmt.Errorf("port must be a number from 1 to 65535 (got %q)", port)
188+ }
189+ return nil
190+}
191+
192+// parseURL accepts an http(s) URL with a host and no credentials, query or
193+// fragment, and returns it without a trailing slash. A path is accepted only
194+// with allowPath.
195+func parseURL(raw string, allowPath bool) (string, error) {
196+ u, err := url.Parse(raw)
197+ if err != nil {
198+ return "", fmt.Errorf("not a valid URL: %v", err)
199+ }
200+ switch {
201+ case u.Scheme != "http" && u.Scheme != "https":
202+ return "", fmt.Errorf("scheme must be http or https (got %q)", u.Scheme)
203+ case u.Host == "" || u.Hostname() == "":
204+ return "", fmt.Errorf("host is missing")
205+ case u.User != nil:
206+ return "", fmt.Errorf("must not contain credentials")
207+ case !allowPath && u.Path != "" && u.Path != "/":
208+ return "", fmt.Errorf("must not contain a path (got %q)", u.Path)
209+ case u.RawQuery != "" || u.ForceQuery:
210+ return "", fmt.Errorf("must not contain a query")
211+ case u.Fragment != "" || strings.Contains(raw, "#"):
212+ return "", fmt.Errorf("must not contain a fragment")
213+ }
214+ return strings.TrimSuffix(u.String(), "/"), nil
215+}
216+
217+func parseCIDRs(raw string) ([]netip.Prefix, error) {
218+ var out []netip.Prefix
219+ for part := range strings.SplitSeq(raw, ",") {
220+ part = strings.TrimSpace(part)
221+ if part == "" {
222+ return nil, fmt.Errorf("empty entry in the comma-separated list")
223+ }
224+ p, err := netip.ParsePrefix(part)
225+ if err != nil {
226+ return nil, fmt.Errorf("%q is not a CIDR such as 10.0.0.0/8 or fd00::/8", part)
227+ }
228+ out = append(out, p.Masked())
229+ }
230+ return out, nil
231+}
A internal/config/config_test.go +158 -0
@@ -0,0 +1,158 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package config_test
4+
5+import (
6+ "crypto/sha256"
7+ "errors"
8+ "net/netip"
9+ "strings"
10+ "testing"
11+
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/require"
14+
15+ "bnema.dev/ermite/internal/config"
16+)
17+
18+const token = "0123456789abcdef0123456789abcdef" // 32 chars
19+
20+func lookup(env map[string]string) func(string) (string, bool) {
21+ return func(k string) (string, bool) {
22+ v, ok := env[k]
23+ return v, ok
24+ }
25+}
26+
27+func valid(overrides map[string]string) map[string]string {
28+ env := map[string]string{
29+ config.EnvDataDir: "/var/lib/ermite",
30+ config.EnvPublicURL: "https://git.example.com",
31+ config.EnvAdminToken: token,
32+ }
33+ for k, v := range overrides {
34+ if v == "\x00unset" {
35+ delete(env, k)
36+ continue
37+ }
38+ env[k] = v
39+ }
40+ return env
41+}
42+
43+func TestLoadDefaults(t *testing.T) {
44+ c, err := config.Load(lookup(valid(nil)))
45+ require.NoError(t, err)
46+ assert.Equal(t, ":7341", c.Addr)
47+ assert.Equal(t, "/var/lib/ermite", c.DataDir)
48+ assert.Equal(t, "https://git.example.com", c.PublicURL)
49+ assert.Equal(t, "git", c.Git)
50+ assert.Equal(t, "https://git.example.com/ermite", c.SourceURL)
51+ assert.Equal(t, "info", c.LogLevel)
52+ assert.Equal(t, "json", c.LogFormat)
53+ assert.Empty(t, c.TrustedProxies)
54+ assert.Equal(t, sha256.Sum256([]byte(token)), c.AdminTokenHash)
55+}
56+
57+func TestLoadAllValues(t *testing.T) {
58+ c, err := config.Load(lookup(valid(map[string]string{
59+ config.EnvAddr: "127.0.0.1:8080",
60+ config.EnvDataDir: "/srv/../srv/data/",
61+ config.EnvPublicURL: "http://localhost:7341/",
62+ config.EnvTrustedProxies: "10.1.2.3/8, fd00::1/8",
63+ config.EnvGit: "/usr/local/bin/git",
64+ config.EnvSourceURL: "https://forge.example.org/src/",
65+ config.EnvLogLevel: "DEBUG",
66+ config.EnvLogFormat: "Console",
67+ })))
68+ require.NoError(t, err)
69+ assert.Equal(t, "127.0.0.1:8080", c.Addr)
70+ assert.Equal(t, "/srv/data", c.DataDir)
71+ assert.Equal(t, "http://localhost:7341", c.PublicURL)
72+ assert.Equal(t, []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8"), netip.MustParsePrefix("fd00::/8")}, c.TrustedProxies)
73+ assert.Equal(t, "/usr/local/bin/git", c.Git)
74+ assert.Equal(t, "https://forge.example.org/src", c.SourceURL)
75+ assert.Equal(t, "debug", c.LogLevel)
76+ assert.Equal(t, "console", c.LogFormat)
77+}
78+
79+func TestLoadSourceURLFollowsPublicURL(t *testing.T) {
80+ c, err := config.Load(lookup(valid(map[string]string{config.EnvPublicURL: "http://127.0.0.1:7341/"})))
81+ require.NoError(t, err)
82+ assert.Equal(t, "http://127.0.0.1:7341/ermite", c.SourceURL)
83+}
84+
85+func TestLoadProblems(t *testing.T) {
86+ tests := []struct {
87+ name string
88+ env map[string]string
89+ want string // variable named in the error
90+ msg string // fragment of its message
91+ }{
92+ {"missing data dir", map[string]string{config.EnvDataDir: "\x00unset"}, config.EnvDataDir, "required"},
93+ {"empty data dir", map[string]string{config.EnvDataDir: ""}, config.EnvDataDir, "required"},
94+ {"relative data dir", map[string]string{config.EnvDataDir: "data"}, config.EnvDataDir, "absolute"},
95+ {"missing public url", map[string]string{config.EnvPublicURL: "\x00unset"}, config.EnvPublicURL, "required"},
96+ {"public url scheme", map[string]string{config.EnvPublicURL: "ftp://x.org"}, config.EnvPublicURL, "scheme"},
97+ {"public url no scheme", map[string]string{config.EnvPublicURL: "git.example.com"}, config.EnvPublicURL, "scheme"},
98+ {"public url no host", map[string]string{config.EnvPublicURL: "https://"}, config.EnvPublicURL, "host"},
99+ {"public url path", map[string]string{config.EnvPublicURL: "https://x.org/forge"}, config.EnvPublicURL, "path"},
100+ {"public url query", map[string]string{config.EnvPublicURL: "https://x.org?a=b"}, config.EnvPublicURL, "query"},
101+ {"public url fragment", map[string]string{config.EnvPublicURL: "https://x.org#top"}, config.EnvPublicURL, "fragment"},
102+ {"public url credentials", map[string]string{config.EnvPublicURL: "https://u:p@x.org"}, config.EnvPublicURL, "credentials"},
103+ {"missing token", map[string]string{config.EnvAdminToken: "\x00unset"}, config.EnvAdminToken, "required"},
104+ {"short token", map[string]string{config.EnvAdminToken: strings.Repeat("a", 31)}, config.EnvAdminToken, "at least 32"},
105+ {"bad addr", map[string]string{config.EnvAddr: "7341"}, config.EnvAddr, "host:port"},
106+ {"bad port", map[string]string{config.EnvAddr: ":http"}, config.EnvAddr, "port"},
107+ {"port zero", map[string]string{config.EnvAddr: ":0"}, config.EnvAddr, "port"},
108+ {"port too big", map[string]string{config.EnvAddr: ":70000"}, config.EnvAddr, "port"},
109+ {"bad cidr", map[string]string{config.EnvTrustedProxies: "10.0.0.1"}, config.EnvTrustedProxies, "CIDR"},
110+ {"empty cidr entry", map[string]string{config.EnvTrustedProxies: "10.0.0.0/8,,"}, config.EnvTrustedProxies, "empty"},
111+ {"bad source url", map[string]string{config.EnvSourceURL: "nope"}, config.EnvSourceURL, "scheme"},
112+ {"bad level", map[string]string{config.EnvLogLevel: "loud"}, config.EnvLogLevel, "trace, debug"},
113+ {"bad format", map[string]string{config.EnvLogFormat: "xml"}, config.EnvLogFormat, "json or console"},
114+ }
115+ for _, tt := range tests {
116+ t.Run(tt.name, func(t *testing.T) {
117+ _, err := config.Load(lookup(valid(tt.env)))
118+ require.Error(t, err)
119+ var ce *config.Error
120+ require.True(t, errors.As(err, &ce))
121+ require.Len(t, ce.Problems, 1, err.Error())
122+ assert.True(t, strings.HasPrefix(ce.Problems[0], tt.want+":"), ce.Problems[0])
123+ assert.Contains(t, ce.Problems[0], tt.msg)
124+ })
125+ }
126+}
127+
128+func TestLoadReportsEveryProblemTogether(t *testing.T) {
129+ _, err := config.Load(lookup(map[string]string{
130+ config.EnvAddr: "bad",
131+ config.EnvLogLevel: "loud",
132+ config.EnvLogFormat: "xml",
133+ }))
134+ require.Error(t, err)
135+ msg := err.Error()
136+ for _, name := range []string{
137+ config.EnvAddr, config.EnvDataDir, config.EnvPublicURL, config.EnvAdminToken, config.EnvLogLevel, config.EnvLogFormat,
138+ } {
139+ assert.Contains(t, msg, name)
140+ }
141+ var ce *config.Error
142+ require.True(t, errors.As(err, &ce))
143+ assert.Len(t, ce.Problems, 6)
144+}
145+
146+func TestErrorNeverContainsToken(t *testing.T) {
147+ short := "secret-but-too-short"
148+ _, err := config.Load(lookup(valid(map[string]string{config.EnvAdminToken: short, config.EnvAddr: "bad"})))
149+ require.Error(t, err)
150+ assert.NotContains(t, err.Error(), short)
151+}
152+
153+func TestTokenLengthCountsCharacters(t *testing.T) {
154+ _, err := config.Load(lookup(valid(map[string]string{config.EnvAdminToken: strings.Repeat("é", 32)})))
155+ assert.NoError(t, err)
156+ _, err = config.Load(lookup(valid(map[string]string{config.EnvAdminToken: strings.Repeat("é", 31)})))
157+ assert.Error(t, err)
158+}
@@ -0,0 +1,158 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package config_test
4+
5+import (
6+ "crypto/sha256"
7+ "errors"
8+ "net/netip"
9+ "strings"
10+ "testing"
11+
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/require"
14+
15+ "bnema.dev/ermite/internal/config"
16+)
17+
18+const token = "0123456789abcdef0123456789abcdef" // 32 chars
19+
20+func lookup(env map[string]string) func(string) (string, bool) {
21+ return func(k string) (string, bool) {
22+ v, ok := env[k]
23+ return v, ok
24+ }
25+}
26+
27+func valid(overrides map[string]string) map[string]string {
28+ env := map[string]string{
29+ config.EnvDataDir: "/var/lib/ermite",
30+ config.EnvPublicURL: "https://git.example.com",
31+ config.EnvAdminToken: token,
32+ }
33+ for k, v := range overrides {
34+ if v == "\x00unset" {
35+ delete(env, k)
36+ continue
37+ }
38+ env[k] = v
39+ }
40+ return env
41+}
42+
43+func TestLoadDefaults(t *testing.T) {
44+ c, err := config.Load(lookup(valid(nil)))
45+ require.NoError(t, err)
46+ assert.Equal(t, ":7341", c.Addr)
47+ assert.Equal(t, "/var/lib/ermite", c.DataDir)
48+ assert.Equal(t, "https://git.example.com", c.PublicURL)
49+ assert.Equal(t, "git", c.Git)
50+ assert.Equal(t, "https://git.example.com/ermite", c.SourceURL)
51+ assert.Equal(t, "info", c.LogLevel)
52+ assert.Equal(t, "json", c.LogFormat)
53+ assert.Empty(t, c.TrustedProxies)
54+ assert.Equal(t, sha256.Sum256([]byte(token)), c.AdminTokenHash)
55+}
56+
57+func TestLoadAllValues(t *testing.T) {
58+ c, err := config.Load(lookup(valid(map[string]string{
59+ config.EnvAddr: "127.0.0.1:8080",
60+ config.EnvDataDir: "/srv/../srv/data/",
61+ config.EnvPublicURL: "http://localhost:7341/",
62+ config.EnvTrustedProxies: "10.1.2.3/8, fd00::1/8",
63+ config.EnvGit: "/usr/local/bin/git",
64+ config.EnvSourceURL: "https://forge.example.org/src/",
65+ config.EnvLogLevel: "DEBUG",
66+ config.EnvLogFormat: "Console",
67+ })))
68+ require.NoError(t, err)
69+ assert.Equal(t, "127.0.0.1:8080", c.Addr)
70+ assert.Equal(t, "/srv/data", c.DataDir)
71+ assert.Equal(t, "http://localhost:7341", c.PublicURL)
72+ assert.Equal(t, []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8"), netip.MustParsePrefix("fd00::/8")}, c.TrustedProxies)
73+ assert.Equal(t, "/usr/local/bin/git", c.Git)
74+ assert.Equal(t, "https://forge.example.org/src", c.SourceURL)
75+ assert.Equal(t, "debug", c.LogLevel)
76+ assert.Equal(t, "console", c.LogFormat)
77+}
78+
79+func TestLoadSourceURLFollowsPublicURL(t *testing.T) {
80+ c, err := config.Load(lookup(valid(map[string]string{config.EnvPublicURL: "http://127.0.0.1:7341/"})))
81+ require.NoError(t, err)
82+ assert.Equal(t, "http://127.0.0.1:7341/ermite", c.SourceURL)
83+}
84+
85+func TestLoadProblems(t *testing.T) {
86+ tests := []struct {
87+ name string
88+ env map[string]string
89+ want string // variable named in the error
90+ msg string // fragment of its message
91+ }{
92+ {"missing data dir", map[string]string{config.EnvDataDir: "\x00unset"}, config.EnvDataDir, "required"},
93+ {"empty data dir", map[string]string{config.EnvDataDir: ""}, config.EnvDataDir, "required"},
94+ {"relative data dir", map[string]string{config.EnvDataDir: "data"}, config.EnvDataDir, "absolute"},
95+ {"missing public url", map[string]string{config.EnvPublicURL: "\x00unset"}, config.EnvPublicURL, "required"},
96+ {"public url scheme", map[string]string{config.EnvPublicURL: "ftp://x.org"}, config.EnvPublicURL, "scheme"},
97+ {"public url no scheme", map[string]string{config.EnvPublicURL: "git.example.com"}, config.EnvPublicURL, "scheme"},
98+ {"public url no host", map[string]string{config.EnvPublicURL: "https://"}, config.EnvPublicURL, "host"},
99+ {"public url path", map[string]string{config.EnvPublicURL: "https://x.org/forge"}, config.EnvPublicURL, "path"},
100+ {"public url query", map[string]string{config.EnvPublicURL: "https://x.org?a=b"}, config.EnvPublicURL, "query"},
101+ {"public url fragment", map[string]string{config.EnvPublicURL: "https://x.org#top"}, config.EnvPublicURL, "fragment"},
102+ {"public url credentials", map[string]string{config.EnvPublicURL: "https://u:p@x.org"}, config.EnvPublicURL, "credentials"},
103+ {"missing token", map[string]string{config.EnvAdminToken: "\x00unset"}, config.EnvAdminToken, "required"},
104+ {"short token", map[string]string{config.EnvAdminToken: strings.Repeat("a", 31)}, config.EnvAdminToken, "at least 32"},
105+ {"bad addr", map[string]string{config.EnvAddr: "7341"}, config.EnvAddr, "host:port"},
106+ {"bad port", map[string]string{config.EnvAddr: ":http"}, config.EnvAddr, "port"},
107+ {"port zero", map[string]string{config.EnvAddr: ":0"}, config.EnvAddr, "port"},
108+ {"port too big", map[string]string{config.EnvAddr: ":70000"}, config.EnvAddr, "port"},
109+ {"bad cidr", map[string]string{config.EnvTrustedProxies: "10.0.0.1"}, config.EnvTrustedProxies, "CIDR"},
110+ {"empty cidr entry", map[string]string{config.EnvTrustedProxies: "10.0.0.0/8,,"}, config.EnvTrustedProxies, "empty"},
111+ {"bad source url", map[string]string{config.EnvSourceURL: "nope"}, config.EnvSourceURL, "scheme"},
112+ {"bad level", map[string]string{config.EnvLogLevel: "loud"}, config.EnvLogLevel, "trace, debug"},
113+ {"bad format", map[string]string{config.EnvLogFormat: "xml"}, config.EnvLogFormat, "json or console"},
114+ }
115+ for _, tt := range tests {
116+ t.Run(tt.name, func(t *testing.T) {
117+ _, err := config.Load(lookup(valid(tt.env)))
118+ require.Error(t, err)
119+ var ce *config.Error
120+ require.True(t, errors.As(err, &ce))
121+ require.Len(t, ce.Problems, 1, err.Error())
122+ assert.True(t, strings.HasPrefix(ce.Problems[0], tt.want+":"), ce.Problems[0])
123+ assert.Contains(t, ce.Problems[0], tt.msg)
124+ })
125+ }
126+}
127+
128+func TestLoadReportsEveryProblemTogether(t *testing.T) {
129+ _, err := config.Load(lookup(map[string]string{
130+ config.EnvAddr: "bad",
131+ config.EnvLogLevel: "loud",
132+ config.EnvLogFormat: "xml",
133+ }))
134+ require.Error(t, err)
135+ msg := err.Error()
136+ for _, name := range []string{
137+ config.EnvAddr, config.EnvDataDir, config.EnvPublicURL, config.EnvAdminToken, config.EnvLogLevel, config.EnvLogFormat,
138+ } {
139+ assert.Contains(t, msg, name)
140+ }
141+ var ce *config.Error
142+ require.True(t, errors.As(err, &ce))
143+ assert.Len(t, ce.Problems, 6)
144+}
145+
146+func TestErrorNeverContainsToken(t *testing.T) {
147+ short := "secret-but-too-short"
148+ _, err := config.Load(lookup(valid(map[string]string{config.EnvAdminToken: short, config.EnvAddr: "bad"})))
149+ require.Error(t, err)
150+ assert.NotContains(t, err.Error(), short)
151+}
152+
153+func TestTokenLengthCountsCharacters(t *testing.T) {
154+ _, err := config.Load(lookup(valid(map[string]string{config.EnvAdminToken: strings.Repeat("é", 32)})))
155+ assert.NoError(t, err)
156+ _, err = config.Load(lookup(valid(map[string]string{config.EnvAdminToken: strings.Repeat("é", 31)})))
157+ assert.Error(t, err)
158+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close