1package brokerconfig
2
3import (
4 "encoding/json"
5 "strings"
6 "testing"
7 "time"
8
9 "github.com/stretchr/testify/require"
10)
11
12// TestParseRouteVariants pins the explicit bounded route union: a bare string
13// is the original Unix path form, an object carries an explicit kind, and every
14// kind/field mismatch or bound violation is refused.
15func TestParseRouteVariants(t *testing.T) {
16 t.Parallel()
17
18 tests := []struct {
19 name string
20 raw string
21 wantErr string
22 check func(t *testing.T, route Route)
23 }{
24 {
25 name: "bare string is a Unix route",
26 raw: `"/tmp/mux.sock"`,
27 check: func(t *testing.T, route Route) {
28 require.Equal(t, RouteUnix, route.Kind())
29 require.Equal(t, "/tmp/mux.sock", route.Path())
30 require.True(t, route.IsLocal())
31 },
32 },
33 {
34 name: "explicit unix route",
35 raw: `{"kind":"unix","path":"/tmp/mux.sock"}`,
36 check: func(t *testing.T, route Route) {
37 require.Equal(t, RouteUnix, route.Kind())
38 require.Equal(t, "/tmp/mux.sock", route.Path())
39 },
40 },
41 {
42 name: "ssh stdio route with trust inputs",
43 raw: `{"kind":"ssh-stdio","target":"user@host:2222","argv":["vev","_broker-mux-stdio","--offline-root","/srv/vev"],` +
44 `"trust":{"knownHostsFile":"/etc/ssh/known_hosts","connectTimeout":"30s"}}`,
45 check: func(t *testing.T, route Route) {
46 require.Equal(t, RouteSSHStdio, route.Kind())
47 require.Equal(t, "user@host:2222", route.Target())
48 require.Equal(t, []string{"vev", "_broker-mux-stdio", "--offline-root", "/srv/vev"}, route.Argv())
49 require.Equal(t, "/etc/ssh/known_hosts", route.KnownHostsFile())
50 require.Equal(t, 30*time.Second, route.ConnectTimeout())
51 require.False(t, route.IsLocal())
52 },
53 },
54 {
55 name: "ssh quic route without trust",
56 raw: `{"kind":"ssh-quic","target":"host","argv":["vev","_broker-mux-quic-bootstrap","--offline-root","/srv/vev"]}`,
57 check: func(t *testing.T, route Route) {
58 require.Equal(t, RouteSSHQUIC, route.Kind())
59 require.Equal(t, "host", route.Target())
60 require.Empty(t, route.KnownHostsFile())
61 require.Zero(t, route.ConnectTimeout())
62 },
63 },
64 {name: "empty route", raw: ``, wantErr: "empty"},
65 {name: "null route", raw: `null`, wantErr: "path string or an object"},
66 {name: "number route", raw: `42`, wantErr: "path string or an object"},
67 {name: "unknown kind", raw: `{"kind":"carrier-pigeon","target":"h","argv":["x"]}`, wantErr: "invalid"},
68 {name: "missing kind", raw: `{"path":"/tmp/mux.sock"}`, wantErr: "requires a kind"},
69 {name: "unknown field", raw: `{"kind":"unix","path":"/tmp/mux.sock","extra":1}`, wantErr: "unknown field"},
70 {name: "trailing json", raw: `{"kind":"unix","path":"/tmp/mux.sock"} {}`, wantErr: "trailing"},
71 {name: "unix carries ssh fields", raw: `{"kind":"unix","path":"/tmp/mux.sock","target":"h"}`, wantErr: "must not carry ssh"},
72 {name: "ssh without argv", raw: `{"kind":"ssh-stdio","target":"h"}`, wantErr: "argv"},
73 {name: "ssh empty argv word", raw: `{"kind":"ssh-stdio","target":"h","argv":["vev",""]}`, wantErr: "empty"},
74 {name: "ssh word control char", raw: `{"kind":"ssh-stdio","target":"h","argv":["vev","a\nb"]}`, wantErr: "control"},
75 {name: "ssh target whitespace", raw: `{"kind":"ssh-stdio","target":"a b","argv":["vev"]}`, wantErr: "whitespace"},
76 {name: "ssh target leading dash", raw: `{"kind":"ssh-stdio","target":"-oProxyCommand=x","argv":["vev"]}`, wantErr: "dash"},
77 {name: "ssh target too long", raw: `{"kind":"ssh-stdio","target":"` + strings.Repeat("a", MaxRouteTargetBytes+1) + `","argv":["vev"]}`, wantErr: "exceeds"},
78 {name: "too many argv words", raw: `{"kind":"ssh-stdio","target":"h","argv":[` + strings.Repeat(`"w",`, MaxRouteArgvWords) + `"w"]}`, wantErr: "words"},
79 {name: "relative trust path", raw: `{"kind":"ssh-stdio","target":"h","argv":["vev"],"trust":{"knownHostsFile":"known_hosts"}}`, wantErr: "not absolute"},
80 {name: "unclean trust path", raw: `{"kind":"ssh-stdio","target":"h","argv":["vev"],"trust":{"knownHostsFile":"/etc/ssh/../known_hosts"}}`, wantErr: "not clean"},
81 {name: "space in trust path", raw: `{"kind":"ssh-stdio","target":"h","argv":["vev"],"trust":{"knownHostsFile":"/etc/ssh/known hosts"}}`, wantErr: "whitespace"},
82 {name: "bad timeout duration", raw: `{"kind":"ssh-stdio","target":"h","argv":["vev"],"trust":{"connectTimeout":"soon"}}`, wantErr: "not a duration"},
83 {name: "negative timeout", raw: `{"kind":"ssh-stdio","target":"h","argv":["vev"],"trust":{"connectTimeout":"-1s"}}`, wantErr: "positive"},
84 {name: "excessive timeout", raw: `{"kind":"ssh-stdio","target":"h","argv":["vev"],"trust":{"connectTimeout":"1h"}}`, wantErr: "between"},
85 }
86 for _, tt := range tests {
87 t.Run(tt.name, func(t *testing.T) {
88 route, err := parseRoute(json.RawMessage(tt.raw))
89 if tt.wantErr != "" {
90 require.Error(t, err)
91 require.Contains(t, err.Error(), tt.wantErr)
92 return
93 }
94 require.NoError(t, err)
95 require.NoError(t, route.Validate())
96 if tt.check != nil {
97 tt.check(t, route)
98 }
99 })
100 }
101}
102
103// TestRouteAddressIsOpaqueAndDeterministic proves the pool address leaks no
104// route detail, is bounded, is stable across parses, and distinguishes distinct
105// routes.
106func TestRouteAddressIsOpaqueAndDeterministic(t *testing.T) {
107 t.Parallel()
108
109 unix, err := parseRoute(json.RawMessage(`{"kind":"unix","path":"/tmp/secret-dir/mux.sock"}`))
110 require.NoError(t, err)
111 stdio, err := parseRoute(json.RawMessage(`{"kind":"ssh-stdio","target":"user@secret-host","argv":["vev","_broker-mux-stdio","--offline-root","/srv/secret"]}`))
112 require.NoError(t, err)
113 quic, err := parseRoute(json.RawMessage(`{"kind":"ssh-quic","target":"user@secret-host","argv":["vev","_broker-mux-quic-bootstrap","--offline-root","/srv/secret"]}`))
114 require.NoError(t, err)
115
116 require.NotEmpty(t, unix.Address())
117 require.LessOrEqual(t, len(unix.Address()), MaxRouteAddressBytes)
118 require.Contains(t, unix.Address(), "offline-route-")
119
120 for _, secret := range []string{"secret-host", "secret-dir", "/srv/secret", "_broker-mux", "mux.sock"} {
121 for _, route := range []Route{unix, stdio, quic} {
122 require.NotContains(t, route.Address(), secret, "address must not leak route detail")
123 }
124 }
125 require.NotEqual(t, unix.Address(), stdio.Address())
126 require.NotEqual(t, stdio.Address(), quic.Address())
127
128 again, err := parseRoute(json.RawMessage(`{"kind":"ssh-stdio","target":"user@secret-host","argv":["vev","_broker-mux-stdio","--offline-root","/srv/secret"]}`))
129 require.NoError(t, err)
130 require.Equal(t, stdio.Address(), again.Address(), "address must be stable across parses")
131
132 // A different trust input is a different route and therefore a different
133 // address.
134 trusted, err := parseRoute(json.RawMessage(`{"kind":"ssh-stdio","target":"user@secret-host","argv":["vev","_broker-mux-stdio","--offline-root","/srv/secret"],"trust":{"connectTimeout":"5s"}}`))
135 require.NoError(t, err)
136 require.NotEqual(t, stdio.Address(), trusted.Address())
137}
138
139// TestConfigLocalMuxRouteRules proves a helper only ever bridges to exactly one
140// provisioned Unix route.
141func TestConfigLocalMuxRouteRules(t *testing.T) {
142 t.Parallel()
143
144 root := t.TempDir()
145 writeConfig(t, root, emptyRouteConfig(), 0o600)
146 layout, err := ResolveLayout(root, nil)
147 require.NoError(t, err)
148 config, err := Load(layout)
149 require.NoError(t, err)
150 _, err = config.LocalMuxRoute()
151 require.Error(t, err)
152 require.Contains(t, err.Error(), "no Unix daemon-mux route")
153
154 root = t.TempDir()
155 writeConfig(t, root, routeConfig(
156 map[string]any{"kind": "unix", "path": "/tmp/one.sock"},
157 map[string]any{"kind": "unix", "path": "/tmp/two.sock"},
158 ), 0o600)
159 layout, err = ResolveLayout(root, nil)
160 require.NoError(t, err)
161 config, err = Load(layout)
162 require.NoError(t, err)
163 _, err = config.LocalMuxRoute()
164 require.Error(t, err)
165 require.Contains(t, err.Error(), "more than one")
166
167 root = t.TempDir()
168 writeConfig(t, root, routeConfig(map[string]any{"kind": "unix", "path": "/tmp/one.sock"}), 0o600)
169 layout, err = ResolveLayout(root, nil)
170 require.NoError(t, err)
171 config, err = Load(layout)
172 require.NoError(t, err)
173 route, err := config.LocalMuxRoute()
174 require.NoError(t, err)
175 require.Equal(t, "/tmp/one.sock", route.Path())
176}
177
178// emptyRouteConfig renders a marker-valid document with no registrations.
179func emptyRouteConfig() map[string]any {
180 return map[string]any{"marker": Marker, "registrations": []any{}}
181}
182
183// routeConfig renders one marker-valid document whose registrations carry the
184// supplied explicit route objects. Each registration gets a distinct endpoint
185// and authenticated identity, so a document with several routes exercises the
186// route rules rather than the one-route-per-pooled-identity rule.
187func routeConfig(routes ...map[string]any) map[string]any {
188 registrations := make([]any, 0, len(routes))
189 for i, route := range routes {
190 registrations = append(registrations, map[string]any{
191 "endpoint": testEndpoint + "-" + string(rune('a'+i)),
192 "incarnation": "0102030405060708090a0b0c0d0e0f10",
193 "generation": 1,
194 "identity": "offline-daemon-" + string(rune('a'+i)),
195 "route": route,
196 "policy": map[string]any{
197 "protocolVersion": 1,
198 "catalogSchemaVersion": 3,
199 "environmentPolicy": "client-owned",
200 "transport": "offline",
201 "trust": "offline-trust",
202 "launch": "offline-launch",
203 "isolation": "offline-isolation",
204 },
205 })
206 }
207 return map[string]any{"marker": Marker, "registrations": registrations}
208}