From 7bec2a1905d5720bc7990a68185a17be99bd89b3 Mon Sep 17 00:00:00 2001 From: reachableceo Date: Fri, 28 Aug 2026 22:30:08 -0500 Subject: [PATCH] Add config loader with strict stdlib-only TOML subset parser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parse keyproxy.toml (listen address, [auth] token_ref, and the mpk- ref map) using a deliberate TOML subset implemented with the standard library only. The surface is strict: unknown keys, malformed refs, and backend-specific misconfigurations (e.g. env refs setting key, file refs missing key) fail loudly at startup. The bearer-token ref must resolve through the file backend. Parse errors carry line numbers, never line contents. 💘 Generated with Crush Assisted-by: Crush:glm-5.2 --- internal/config/config.go | 157 ++++++++++++++++++++ internal/config/config_test.go | 196 +++++++++++++++++++++++++ internal/config/toml.go | 255 +++++++++++++++++++++++++++++++++ internal/config/toml_test.go | 97 +++++++++++++ 4 files changed, 705 insertions(+) create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go create mode 100644 internal/config/toml.go create mode 100644 internal/config/toml_test.go diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..2cd63ac --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,157 @@ +// Package config loads keyproxy.toml: the listen address, the bearer +// auth ref, and the ref map (mpk- -> backend/source/key). The +// config holds locations only — never material; keyproxy.toml.example is +// tracked, the real file is gitignored. +package config + +import ( + "fmt" + "os" + "regexp" + "sort" + "strconv" + "strings" + + "git.knownelement.com/ukrrs/mopac-keyproxy/internal/backend" +) + +// DefaultListen is the loopback bind address used when [listen] is unset. +const DefaultListen = "127.0.0.1:8082" + +// knownBackends are the backend names the loader accepts. +var knownBackends = map[string]bool{ + "file": true, + "env": true, + "bitwarden": true, + "vault": true, +} + +// refPattern is the shape every consumer-facing ref must have +// (mpk-, lowercase/digits/hyphens). Because the pattern is +// enforced before any echo, log lines and error bodies can name refs +// safely: an mpk- ref is a placeholder, by construction not material. +var refPattern = regexp.MustCompile(`^mpk-[a-z0-9][a-z0-9-]*$`) + +// ValidRef reports whether name is a well-formed mpk- ref. +func ValidRef(name string) bool { return refPattern.MatchString(name) } + +// Config is the parsed keyproxy.toml. +type Config struct { + Listen string // bind address, default 127.0.0.1:8082 + AuthTokenRef string // [auth] token_ref; must be a file-backend ref + Refs map[string]backend.Ref // ref name -> backend ref + RefNames []string // sorted ref names +} + +// Load reads, parses and validates the config at path. +func Load(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read config: %w", err) + } + doc, err := parseTOML(data) + if err != nil { + return nil, fmt.Errorf("%s: %w", path, err) + } + cfg := &Config{Refs: map[string]backend.Ref{}} + + // Strict surface: unknown keys fail loudly (a misconfigured file must + // not start on typo'd best effort). + for _, k := range doc.Keys() { + switch k { + case "listen", "auth", "refs": + default: + return nil, fmt.Errorf("%s: unknown top-level key %q (allowed: listen, auth, refs)", path, k) + } + } + + if s, ok := doc.Str("listen"); ok { + cfg.Listen = s + } else { + cfg.Listen = DefaultListen + } + + auth := doc.Table("auth") + for _, k := range auth.Keys() { + if k != "token_ref" { + return nil, fmt.Errorf("%s: [auth]: unknown key %q (allowed: token_ref)", path, k) + } + } + cfg.AuthTokenRef, _ = auth.Str("token_ref") + if cfg.AuthTokenRef == "" { + return nil, fmt.Errorf("[auth]: token_ref is required (bearer token for /v1/resolve, resolved through the file backend)") + } + + refs := doc.Table("refs") + if len(refs) == 0 { + return nil, fmt.Errorf("[refs]: no refs configured") + } + for _, name := range refs.Keys() { + ref, err := parseRef(name, refs.Table(name)) + if err != nil { + return nil, err + } + cfg.Refs[name] = ref + cfg.RefNames = append(cfg.RefNames, name) + } + sort.Strings(cfg.RefNames) + + authRef, ok := cfg.Refs[cfg.AuthTokenRef] + if !ok { + return nil, fmt.Errorf("[auth]: token_ref %q is not a configured ref", cfg.AuthTokenRef) + } + if authRef.Backend != "file" { + return nil, fmt.Errorf("[auth]: token_ref %q must use the file backend (auth is bootstrapped from a 0600 env file), got %q", cfg.AuthTokenRef, authRef.Backend) + } + return cfg, nil +} + +// parseRef validates one [refs."mpk-"] table. +func parseRef(name string, t Doc) (backend.Ref, error) { + if !ValidRef(name) { + return backend.Ref{}, fmt.Errorf(`[refs."%s"]: ref name must match mpk- (lowercase alphanumerics and hyphens)`, name) + } + ref := backend.Ref{Name: name} + ref.Backend, _ = t.Str("backend") + if !knownBackends[ref.Backend] { + return backend.Ref{}, fmt.Errorf(`[refs."%s"]: backend must be one of file, env, bitwarden, vault`, name) + } + ref.Source, _ = t.Str("source") + ref.Key, _ = t.Str("key") + ref.Mode, _ = t.Str("mode") + if ref.Mode == "" { + ref.Mode = "0600" + } + + for _, k := range t.Keys() { + switch k { + case "backend", "source", "key", "mode": + default: + return backend.Ref{}, fmt.Errorf(`[refs."%s"]: unknown key %q (allowed: backend, source, key, mode)`, name, k) + } + } + + switch ref.Backend { + case "file": + if ref.Source == "" { + return backend.Ref{}, fmt.Errorf(`[refs."%s"]: file backend requires source (path to a 0600 env file)`, name) + } + if ref.Key == "" { + return backend.Ref{}, fmt.Errorf(`[refs."%s"]: file backend requires key (KEY inside the env file)`, name) + } + if m, err := strconv.ParseUint(strings.TrimPrefix(ref.Mode, "0o"), 8, 32); err != nil || os.FileMode(m)&os.ModePerm == 0 { + return backend.Ref{}, fmt.Errorf(`[refs."%s"]: mode must be an octal permission mask like "0600"`, name) + } + case "env": + if ref.Source == "" { + return backend.Ref{}, fmt.Errorf(`[refs."%s"]: env backend requires source (environment variable name)`, name) + } + if ref.Key != "" { + return backend.Ref{}, fmt.Errorf(`[refs."%s"]: env backend must not set key (source IS the variable name)`, name) + } + if _, ok := t.Str("mode"); ok { + return backend.Ref{}, fmt.Errorf(`[refs."%s"]: mode applies to the file backend only`, name) + } + } + return ref, nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..18ee907 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,196 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +const validConfig = ` +listen = "127.0.0.1:9999" + +[auth] +token_ref = "mpk-self" + +[refs."mpk-self"] +backend = "file" +source = "/tmp/keyproxy.env" +key = "KEYPROXY_TOKEN" + +[refs."mpk-example"] +backend = "file" +source = "/tmp/example.env" +key = "EXAMPLE_API_KEY" + +[refs."mpk-example-env"] +backend = "env" +source = "EXAMPLE_API_KEY" + +[refs."mpk-stub-bw"] +backend = "bitwarden" +source = "sm://p/example" +key = "EXAMPLE_API_KEY" + +[refs."mpk-stub-vault"] +backend = "vault" +` + +func writeConfig(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "keyproxy.toml") + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + return path +} + +func TestLoadValid(t *testing.T) { + cfg, err := Load(writeConfig(t, validConfig)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg.Listen != "127.0.0.1:9999" { + t.Fatalf("listen = %q", cfg.Listen) + } + if cfg.AuthTokenRef != "mpk-self" { + t.Fatalf("auth token ref = %q", cfg.AuthTokenRef) + } + if got := strings.Join(cfg.RefNames, ","); got != "mpk-example,mpk-example-env,mpk-self,mpk-stub-bw,mpk-stub-vault" { + t.Fatalf("ref names = %q", got) + } + file := cfg.Refs["mpk-example"] + if file.Backend != "file" || file.Source != "/tmp/example.env" || file.Key != "EXAMPLE_API_KEY" || file.Mode != "0600" { + t.Fatalf("file ref parsed wrong: %+v", file) + } + if cfg.Refs["mpk-stub-bw"].Backend != "bitwarden" { + t.Fatalf("stub ref parsed wrong: %+v", cfg.Refs["mpk-stub-bw"]) + } +} + +func TestLoadDefaults(t *testing.T) { + cfg, err := Load(writeConfig(t, strings.Replace(validConfig, `listen = "127.0.0.1:9999"`, "", 1))) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg.Listen != DefaultListen { + t.Fatalf("default listen = %q, want %q", cfg.Listen, DefaultListen) + } +} + +func TestLoadErrors(t *testing.T) { + tests := []struct { + name string + mutate func(string) string + wantErr string + }{ + { + name: "missing file", + mutate: func(string) string { return "" }, + wantErr: "read config", + }, + { + name: "bad ref name", + mutate: func(s string) string { return strings.Replace(s, `mpk-example"`, `mpk_ExAMPLE"`, 1) }, + wantErr: "must match mpk-", + }, + { + name: "unknown backend", + mutate: func(s string) string { return strings.Replace(s, `backend = "env"`, `backend = "s3"`, 1) }, + wantErr: "backend must be one of", + }, + { + name: "file ref without source", + mutate: func(s string) string { return strings.Replace(s, `source = "/tmp/example.env"`, "", 1) }, + wantErr: "file backend requires source", + }, + { + name: "file ref without key", + mutate: func(s string) string { return strings.Replace(s, `key = "EXAMPLE_API_KEY"`, "", 1) }, + wantErr: "file backend requires key", + }, + { + name: "file ref with junk mode", + mutate: func(s string) string { return strings.Replace(s, `key = "EXAMPLE_API_KEY"`, `key = "EXAMPLE_API_KEY"`+"\n"+`mode = "readable"`, 1) }, + wantErr: "mode must be an octal", + }, + { + name: "env ref with key", + mutate: func(s string) string { return strings.Replace(s, `source = "EXAMPLE_API_KEY"`, `source = "EXAMPLE_API_KEY"`+"\n"+`key = "X"`, 1) }, + wantErr: "env backend must not set key", + }, + { + name: "env ref with mode", + mutate: func(s string) string { return strings.Replace(s, `source = "EXAMPLE_API_KEY"`, `source = "EXAMPLE_API_KEY"`+"\n"+`mode = "0644"`, 1) }, + wantErr: "mode applies to the file backend only", + }, + { + name: "unknown key inside ref table", + mutate: func(s string) string { return strings.Replace(s, `key = "EXAMPLE_API_KEY"`, `key = "EXAMPLE_API_KEY"`+"\n"+`ttl = "5m"`, 1) }, + wantErr: "unknown key", + }, + { + name: "no auth section", + mutate: func(s string) string { return strings.Replace(s, "token_ref = \"mpk-self\"", "", 1) }, + wantErr: "token_ref is required", + }, + { + name: "auth ref not configured", + mutate: func(s string) string { return strings.Replace(s, `token_ref = "mpk-self"`, `token_ref = "mpk-ghost"`, 1) }, + wantErr: "not a configured ref", + }, + { + name: "auth ref not file backend", + mutate: func(s string) string { + return strings.Replace(s, `token_ref = "mpk-self"`, `token_ref = "mpk-example-env"`, 1) + }, + wantErr: "must use the file backend", + }, + { + name: "no refs at all", + mutate: func(s string) string { return "listen = \"127.0.0.1:9999\"\n[auth]\ntoken_ref = \"mpk-self\"\n" }, + wantErr: "no refs configured", + }, + { + name: "unknown top-level key", + mutate: func(s string) string { return "ttl = \"5m\"\n" + s }, + wantErr: "unknown top-level key", + }, + { + name: "unknown auth key", + mutate: func(s string) string { return strings.Replace(s, "token_ref = \"mpk-self\"", "token_ref = \"mpk-self\"\nrotation = \"7d\"", 1) }, + wantErr: "unknown key", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + content := tt.mutate(validConfig) + path := writeConfig(t, content) + if content == "" { + path = filepath.Join(t.TempDir(), "absent.toml") + } + cfg, err := Load(path) + if err == nil { + t.Fatalf("want error %q, got config %+v", tt.wantErr, cfg) + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("error %q does not contain %q", err, tt.wantErr) + } + }) + } +} + +func TestValidRef(t *testing.T) { + valid := []string{"mpk-a", "mpk-redmine", "mpk-a-1", "mpk-9"} + invalid := []string{"", "redmine", "MPK-a", "mpk-", "mpk--a", "mpk_a", "mpk-a_b", "mpk-a.b", "mpk-a b", "sk-live-pasted-secret"} + for _, r := range valid { + if !ValidRef(r) { + t.Errorf("ValidRef(%q) = false, want true", r) + } + } + for _, r := range invalid { + if ValidRef(r) { + t.Errorf("ValidRef(%q) = true, want false", r) + } + } +} diff --git a/internal/config/toml.go b/internal/config/toml.go new file mode 100644 index 0000000..842efc5 --- /dev/null +++ b/internal/config/toml.go @@ -0,0 +1,255 @@ +package config + +import ( + "fmt" + "sort" + "strconv" + "strings" +) + +// Doc is the parsed representation of keyproxy.toml: nested tables are +// Doc values; leaves are string, int64 or bool. This is a deliberate +// stdlib-only TOML subset (tables with quoted keys, basic strings with +// escapes, integers, booleans, comments). Anything richer is a parse +// error so a misconfigured file fails loudly instead of silently. +type Doc map[string]any + +// Table returns the nested table at the key path, or an empty doc. +func (d Doc) Table(keys ...string) Doc { + cur := any(d) + for _, k := range keys { + m, ok := cur.(Doc) + if !ok { + return Doc{} + } + cur = m[k] + } + if m, ok := cur.(Doc); ok { + return m + } + return Doc{} +} + +// Str returns the string leaf at key. +func (d Doc) Str(key string) (string, bool) { + v, ok := d[key].(string) + return v, ok +} + +// Int returns the int64 leaf at key. +func (d Doc) Int(key string) (int64, bool) { + v, ok := d[key].(int64) + return v, ok +} + +// Bool returns the bool leaf at key. +func (d Doc) Bool(key string) (bool, bool) { + v, ok := d[key].(bool) + return v, ok +} + +// Keys lists the doc's keys, sorted. +func (d Doc) Keys() []string { + out := make([]string, 0, len(d)) + for k := range d { + out = append(out, k) + } + sort.Strings(out) + return out +} + +// parseTOML parses the TOML subset into a Doc. Errors carry line +// numbers, never line contents (a config file may sit next to secrets +// someone pasted in by mistake). +func parseTOML(data []byte) (Doc, error) { + root := Doc{} + cur := root + for i, raw := range strings.Split(string(data), "\n") { + lineno := i + 1 + line := stripComment(raw) + line = strings.TrimSpace(line) + if line == "" { + continue + } + if strings.HasPrefix(line, "[") { + if !strings.HasSuffix(line, "]") { + return nil, fmt.Errorf("line %d: malformed table header", lineno) + } + path, err := splitKeyPath(strings.TrimSpace(line[1 : len(line)-1])) + if err != nil || len(path) == 0 { + return nil, fmt.Errorf("line %d: malformed table header", lineno) + } + cur = root + for _, seg := range path { + next, ok := cur[seg].(Doc) + if !ok { + if _, exists := cur[seg]; exists { + return nil, fmt.Errorf("line %d: table conflicts with value key %q", lineno, seg) + } + next = Doc{} + cur[seg] = next + } + cur = next + } + continue + } + eq := strings.IndexByte(line, '=') + if eq < 1 { + return nil, fmt.Errorf("line %d: expected key = value", lineno) + } + key := strings.TrimSpace(line[:eq]) + key = strings.Trim(key, `"'`) + if key == "" || strings.ContainsAny(key, `".[]`) { + return nil, fmt.Errorf("line %d: malformed key", lineno) + } + value := strings.TrimSpace(line[eq+1:]) + parsed, err := parseValue(value) + if err != nil { + return nil, fmt.Errorf("line %d: %v", lineno, err) + } + if _, dup := cur[key]; dup { + return nil, fmt.Errorf("line %d: duplicate key %q", lineno, key) + } + cur[key] = parsed + } + return root, nil +} + +// splitKeyPath splits a.b."c.d" into [a b c.d]; each segment is bare or +// double-quoted. +func splitKeyPath(s string) ([]string, error) { + var segs []string + for i := 0; i < len(s); { + for i < len(s) && (s[i] == ' ' || s[i] == '\t') { + i++ + } + if i >= len(s) { + break + } + switch s[i] { + case '"': + j := i + 1 + for j < len(s) && s[j] != '"' { + j++ + } + if j >= len(s) { + return nil, fmt.Errorf("unterminated quoted key") + } + segs = append(segs, s[i+1:j]) + i = j + 1 + default: + j := i + for j < len(s) && s[j] != '.' { + j++ + } + seg := strings.TrimSpace(s[i:j]) + if seg == "" { + return nil, fmt.Errorf("empty key segment") + } + segs = append(segs, seg) + i = j + } + for i < len(s) && (s[i] == ' ' || s[i] == '\t') { + i++ + } + if i < len(s) { + if s[i] != '.' { + return nil, fmt.Errorf("unexpected character in table header") + } + i++ + } + } + return segs, nil +} + +// parseValue parses a basic string, integer or boolean. +func parseValue(v string) (any, error) { + if v == "" { + return nil, fmt.Errorf("missing value") + } + switch v[0] { + case '"': + s, rest, err := parseBasicString(v) + if err != nil { + return nil, err + } + if strings.TrimSpace(rest) != "" { + return nil, fmt.Errorf("trailing characters after string value") + } + return s, nil + case '\'': + end := strings.IndexByte(v[1:], '\'') + if end < 0 { + return nil, fmt.Errorf("unterminated literal string") + } + s := v[1 : 1+end] + if strings.TrimSpace(v[2+end:]) != "" { + return nil, fmt.Errorf("trailing characters after string value") + } + return s, nil + } + switch v { + case "true": + return true, nil + case "false": + return false, nil + } + n, err := strconv.ParseInt(v, 10, 64) + if err != nil { + return nil, fmt.Errorf("unsupported value (expected string, int or bool)") + } + return n, nil +} + +// parseBasicString parses one "..." basic string with \\ \" \n \t \r +// escapes; it returns the string and the unparsed remainder. +func parseBasicString(v string) (string, string, error) { + var b strings.Builder + for i := 1; i < len(v); i++ { + switch v[i] { + case '"': + return b.String(), v[i+1:], nil + case '\\': + if i+1 >= len(v) { + return "", "", fmt.Errorf("dangling escape in string") + } + i++ + switch v[i] { + case '\\': + b.WriteByte('\\') + case '"': + b.WriteByte('"') + case 'n': + b.WriteByte('\n') + case 't': + b.WriteByte('\t') + case 'r': + b.WriteByte('\r') + default: + return "", "", fmt.Errorf("unsupported escape in string") + } + default: + b.WriteByte(v[i]) + } + } + return "", "", fmt.Errorf("unterminated string") +} + +// stripComment removes a trailing # comment that sits outside quotes. +func stripComment(line string) string { + var quote byte + for i := 0; i < len(line); i++ { + c := line[i] + switch { + case quote != 0: + if c == quote { + quote = 0 + } + case c == '"' || c == '\'': + quote = c + case c == '#': + return line[:i] + } + } + return line +} diff --git a/internal/config/toml_test.go b/internal/config/toml_test.go new file mode 100644 index 0000000..6a28567 --- /dev/null +++ b/internal/config/toml_test.go @@ -0,0 +1,97 @@ +package config + +import ( + "strings" + "testing" +) + +func TestParseTOML(t *testing.T) { + tests := []struct { + name string + in string + want map[string]any // dotted paths -> expected values + wantErr string + }{ + { + name: "scalars and comments", + in: "# top\nlisten = \"127.0.0.1:8082\" # inline\nflag = true\nnum = 42\n", + want: map[string]any{"listen": "127.0.0.1:8082", "flag": true, "num": int64(42)}, + }, + { + name: "tables with quoted dotted keys", + in: "[refs.\"mpk-redmine\"]\nbackend = \"file\"\n\n[refs.\"mpk-env\"]\nbackend = \"env\"\n", + want: map[string]any{"refs.mpk-redmine.backend": "file", "refs.mpk-env.backend": "env"}, + }, + { + name: "escapes in basic strings", + in: "a = \"x\\ty\\\"z\"\n", + want: map[string]any{"a": "x\ty\"z"}, + }, + { + name: "literal strings keep backslashes", + in: "a = 'C:\\path'\n", + want: map[string]any{"a": `C:\path`}, + }, + { + name: "duplicate key", + in: "a = \"1\"\na = \"2\"\n", + wantErr: "duplicate key", + }, + { + name: "unterminated string", + in: "a = \"oops\n", + wantErr: "unterminated string", + }, + { + name: "unsupported richer syntax fails loudly", + in: "a = [\"x\", \"y\"]\n", + wantErr: "unsupported value", + }, + { + name: "malformed table header", + in: "[refs\n", + wantErr: "malformed table header", + }, + { + name: "bare line without equals", + in: "garbage\n", + wantErr: "expected key = value", + }, + { + name: "table conflicts with scalar", + in: "refs = \"x\"\n[refs.\"mpk-a\"]\nbackend = \"file\"\n", + wantErr: "conflicts", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + doc, err := parseTOML([]byte(tt.in)) + if tt.wantErr != "" { + if err == nil { + t.Fatalf("want error %q, got doc %v", tt.wantErr, doc) + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("error %q does not contain %q", err, tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + for path, want := range tt.want { + segs := strings.Split(path, ".") + cur := any(doc) + for _, seg := range segs { + m, ok := cur.(Doc) + if !ok { + t.Fatalf("path %s: %v is not a table", path, cur) + } + cur = m[seg] + } + if cur != want { + t.Fatalf("path %s: got %#v, want %#v", path, cur, want) + } + } + }) + } +}