返回 DeepSeek-Reasonix
remote_hosts_test.go
根目录 / desktop / remote_hosts_test.go
1 package main
2
3 import (
4 "encoding/json"
5 "errors"
6 "fmt"
7 "os"
8 "path/filepath"
9 "runtime"
10 "strings"
11 "sync"
12 "testing"
13 )
14
15 func TestRemoteHostStoreRoundTripPrivateAndSecretFree(t *testing.T) {
16 path := filepath.Join(t.TempDir(), "remote", "hosts.json")
17 store, err := NewRemoteHostStore(path)
18 if err != nil {
19 t.Fatal(err)
20 }
21 entry, err := NewRemoteHostEntry("lab-linux", "Lab Linux")
22 if err != nil {
23 t.Fatal(err)
24 }
25 entry.ResumeLeaseID = "lease_opaque"
26 entry.LayoutRef = "layout_lab"
27 entry.SSHConfigPath = filepath.Join(filepath.Dir(path), "ssh config")
28 if err := store.Upsert(entry); err != nil {
29 t.Fatal(err)
30 }
31 hosts, err := store.Load()
32 if err != nil {
33 t.Fatal(err)
34 }
35 if len(hosts) != 1 || hosts[0] != entry {
36 t.Fatalf("hosts = %#v, want %#v", hosts, entry)
37 }
38 info, err := os.Stat(path)
39 if err != nil {
40 t.Fatal(err)
41 }
42 if runtime.GOOS != "windows" && info.Mode().Perm() != 0o600 {
43 t.Fatalf("mode = %04o, want 0600", info.Mode().Perm())
44 }
45 raw, err := os.ReadFile(path)
46 if err != nil {
47 t.Fatal(err)
48 }
49 for _, forbidden := range []string{"password", "passphrase", "privateKey", "askPass", "secret"} {
50 if strings.Contains(strings.ToLower(string(raw)), strings.ToLower(`"`+forbidden+`"`)) {
51 t.Fatalf("store contains forbidden secret field %q: %s", forbidden, raw)
52 }
53 }
54 var object map[string]any
55 if err := json.Unmarshal(raw, &object); err != nil {
56 t.Fatal(err)
57 }
58 entries := object["hosts"].([]any)
59 stored := entries[0].(map[string]any)
60 if len(stored) != 8 {
61 t.Fatalf("stored fields = %#v, want exactly the frozen non-secret fields", stored)
62 }
63 }
64
65 func TestRemoteHostStoreDirectRoundTripAndDuplicateIdentity(t *testing.T) {
66 store, err := NewRemoteHostStore(filepath.Join(t.TempDir(), "hosts.json"))
67 if err != nil {
68 t.Fatal(err)
69 }
70 first, err := NewRemoteDirectHostEntry("Builder@EXAMPLE.com.", 2222, "Builder")
71 if err != nil {
72 t.Fatal(err)
73 }
74 if first.Destination != "Builder@example.com" || first.Mode != RemoteHostConnectionDirect || first.Port != 2222 {
75 t.Fatalf("canonical direct entry = %#v", first)
76 }
77 if err := store.Upsert(first); err != nil {
78 t.Fatal(err)
79 }
80 second, err := NewRemoteDirectHostEntry("Builder@example.com", 2222, "Duplicate")
81 if err != nil {
82 t.Fatal(err)
83 }
84 if err := store.Upsert(second); err == nil || !strings.Contains(err.Error(), "duplicate SSH Host") {
85 t.Fatalf("duplicate direct identity error = %v", err)
86 }
87 hosts, err := store.Load()
88 if err != nil {
89 t.Fatal(err)
90 }
91 if len(hosts) != 1 || hosts[0] != first {
92 t.Fatalf("direct hosts = %#v, want %#v", hosts, first)
93 }
94 }
95
96 func TestRemoteHostStoreLoadsV1AsConfigAndWritesV2OnMutation(t *testing.T) {
97 path := filepath.Join(t.TempDir(), "hosts.json")
98 configPath := filepath.Join(t.TempDir(), "legacy ssh config")
99 entry, err := NewRemoteHostEntry("legacy-host", "Legacy Host")
100 if err != nil {
101 t.Fatal(err)
102 }
103 raw := []byte(fmt.Sprintf(
104 `{"version":1,"hosts":[{"id":%q,"alias":%q,"label":%q,"sshConfigPath":%q,"clientInstanceId":%q,"resumeLeaseId":"lease_legacy","layoutRef":"layout_legacy"}]}`,
105 entry.ID, entry.Alias, entry.Label, configPath, entry.ClientInstanceID,
106 ))
107 if err := os.WriteFile(path, raw, 0o600); err != nil {
108 t.Fatal(err)
109 }
110 store, err := NewRemoteHostStore(path)
111 if err != nil {
112 t.Fatal(err)
113 }
114 hosts, err := store.Load()
115 if err != nil {
116 t.Fatal(err)
117 }
118 if len(hosts) != 1 || hosts[0].Mode != RemoteHostConnectionConfig || hosts[0].Alias != "legacy-host" || hosts[0].SSHConfigPath != configPath || hosts[0].ResumeLeaseID != "lease_legacy" {
119 t.Fatalf("migrated v1 host = %#v", hosts)
120 }
121 if err := store.UpdateLayoutRef(entry.ID, "layout_v2"); err != nil {
122 t.Fatal(err)
123 }
124 after, err := os.ReadFile(path)
125 if err != nil {
126 t.Fatal(err)
127 }
128 var document remoteHostStoreDocument
129 if err := json.Unmarshal(after, &document); err != nil {
130 t.Fatal(err)
131 }
132 if document.Version != remoteHostStoreVersion || len(document.Hosts) != 1 || document.Hosts[0].Mode != RemoteHostConnectionConfig || document.Hosts[0].SSHConfigPath != configPath {
133 t.Fatalf("persisted migration = %s", after)
134 }
135 }
136
137 func TestParseRemoteSSHDirectDestination(t *testing.T) {
138 valid := map[string]RemoteSSHDirectTarget{
139 "taibai@192.168.1.20": {Username: "taibai", Host: "192.168.1.20"},
140 "build_user@EXAMPLE.COM.": {Username: "build_user", Host: "example.com"},
141 "root@[2001:0db8:0:0::10]": {Username: "root", Host: "2001:db8::10"},
142 }
143 for raw, want := range valid {
144 got, err := ParseRemoteSSHDirectDestination(raw)
145 if err != nil || got != want {
146 t.Errorf("ParseRemoteSSHDirectDestination(%q) = %#v, %v, want %#v", raw, got, err, want)
147 }
148 }
149 invalid := []string{
150 "", "host", "@host", "user@", "user@@host", "-evil@host", "user name@host",
151 "user@-oProxyCommand=evil", "user@host name", "user@host;evil", "user@../host",
152 "user@2001:db8::10", "user@[192.168.1.1]", "user@[bad::address]", "user@999.999.999.999",
153 "user@host:2222", " user@host", "user@host\n",
154 }
155 for _, raw := range invalid {
156 if _, err := ParseRemoteSSHDirectDestination(raw); err == nil {
157 t.Errorf("ParseRemoteSSHDirectDestination(%q) unexpectedly succeeded", raw)
158 }
159 }
160 for _, port := range []int{-1, 0, 65536} {
161 if err := ValidateRemoteSSHPort(port); err == nil {
162 t.Errorf("ValidateRemoteSSHPort(%d) unexpectedly succeeded", port)
163 }
164 }
165 for _, port := range []int{1, 22, 65535} {
166 if err := ValidateRemoteSSHPort(port); err != nil {
167 t.Errorf("ValidateRemoteSSHPort(%d): %v", port, err)
168 }
169 }
170 }
171
172 func TestRemoteHostDisplayConnectionFormatsIPv6WithoutDoubleBrackets(t *testing.T) {
173 entry, err := NewRemoteDirectHostEntry("builder@[2001:db8::10]", 2222, "Builder")
174 if err != nil {
175 t.Fatal(err)
176 }
177 if got, want := remoteHostDisplayConnection(entry), "builder@[2001:db8::10]:2222"; got != want {
178 t.Fatalf("remoteHostDisplayConnection() = %q, want %q", got, want)
179 }
180 }
181
182 func TestRemoteHostStoreCorruptionFailsClosedAndIsNotOverwritten(t *testing.T) {
183 path := filepath.Join(t.TempDir(), "hosts.json")
184 raw := []byte(`{"version":1,"hosts":[{"alias":"host","label":"Host","clientInstanceId":"client","password":"must-not-load"}]}`)
185 if err := os.WriteFile(path, raw, 0o600); err != nil {
186 t.Fatal(err)
187 }
188 store, err := NewRemoteHostStore(path)
189 if err != nil {
190 t.Fatal(err)
191 }
192 if _, err := store.Load(); !errors.Is(err, ErrRemoteHostStoreCorrupt) {
193 t.Fatalf("Load error = %v, want ErrRemoteHostStoreCorrupt", err)
194 }
195 entry, err := NewRemoteHostEntry("replacement", "Replacement")
196 if err != nil {
197 t.Fatal(err)
198 }
199 if err := store.Upsert(entry); !errors.Is(err, ErrRemoteHostStoreCorrupt) {
200 t.Fatalf("Upsert error = %v, want ErrRemoteHostStoreCorrupt", err)
201 }
202 after, err := os.ReadFile(path)
203 if err != nil {
204 t.Fatal(err)
205 }
206 if string(after) != string(raw) {
207 t.Fatalf("corrupt store was overwritten:\n%s", after)
208 }
209 }
210
211 func TestRemoteHostStoreConcurrentInstancesDoNotLoseUpdates(t *testing.T) {
212 path := filepath.Join(t.TempDir(), "hosts.json")
213 const count = 48
214 var wg sync.WaitGroup
215 errorsCh := make(chan error, count)
216 for i := 0; i < count; i++ {
217 wg.Add(1)
218 go func(i int) {
219 defer wg.Done()
220 store, err := NewRemoteHostStore(path)
221 if err != nil {
222 errorsCh <- err
223 return
224 }
225 entry, err := NewRemoteHostEntry(fmt.Sprintf("host-%02d", i), fmt.Sprintf("Host %02d", i))
226 if err == nil {
227 err = store.Upsert(entry)
228 }
229 if err != nil {
230 errorsCh <- err
231 }
232 }(i)
233 }
234 wg.Wait()
235 close(errorsCh)
236 for err := range errorsCh {
237 t.Fatal(err)
238 }
239 store, _ := NewRemoteHostStore(path)
240 hosts, err := store.Load()
241 if err != nil {
242 t.Fatal(err)
243 }
244 if len(hosts) != count {
245 t.Fatalf("host count = %d, want %d", len(hosts), count)
246 }
247 for i := 1; i < len(hosts); i++ {
248 if hosts[i-1].ID >= hosts[i].ID {
249 t.Fatalf("hosts not deterministically sorted: %q then %q", hosts[i-1].ID, hosts[i].ID)
250 }
251 }
252 }
253
254 func TestRemoteHostStoreLeaseAndLayoutUpdates(t *testing.T) {
255 store, err := NewRemoteHostStore(filepath.Join(t.TempDir(), "hosts.json"))
256 if err != nil {
257 t.Fatal(err)
258 }
259 entry, _ := NewRemoteHostEntry("buildbox", "Build Box")
260 if err := store.Upsert(entry); err != nil {
261 t.Fatal(err)
262 }
263 if err := store.UpdateResumeLease(entry.ID, "lease_new"); err != nil {
264 t.Fatal(err)
265 }
266 if err := store.UpdateLayoutRef(entry.ID, "layout_new"); err != nil {
267 t.Fatal(err)
268 }
269 hosts, err := store.Load()
270 if err != nil {
271 t.Fatal(err)
272 }
273 if hosts[0].ClientInstanceID != entry.ClientInstanceID || hosts[0].ResumeLeaseID != "lease_new" || hosts[0].LayoutRef != "layout_new" {
274 t.Fatalf("updated host = %#v", hosts[0])
275 }
276 if err := store.UpdateResumeLease(entry.ID, ""); err != nil {
277 t.Fatal(err)
278 }
279 }
280
281 func TestRemoteHostAliasRejectsArgumentAndShellInjection(t *testing.T) {
282 for _, alias := range []string{
283 "", "-oProxyCommand=calc", "host name", "user@host", "host;touch-pwned",
284 "host\nProxyCommand evil", "../host", strings.Repeat("a", 256),
285 } {
286 if err := ValidateRemoteHostAlias(alias); err == nil {
287 t.Errorf("ValidateRemoteHostAlias(%q) unexpectedly succeeded", alias)
288 }
289 }
290 for _, alias := range []string{"host", "lab-linux", "prod.example_2"} {
291 if err := ValidateRemoteHostAlias(alias); err != nil {
292 t.Errorf("ValidateRemoteHostAlias(%q): %v", alias, err)
293 }
294 }
295 }
296
297 func TestRemoteHostStoreRejectsSymlinkFile(t *testing.T) {
298 if runtime.GOOS == "windows" {
299 t.Skip("symlink creation requires privileges on some Windows builders")
300 }
301 dir := t.TempDir()
302 target := filepath.Join(dir, "outside.json")
303 if err := os.WriteFile(target, []byte(`{"version":1,"hosts":[]}`), 0o600); err != nil {
304 t.Fatal(err)
305 }
306 link := filepath.Join(dir, "hosts.json")
307 if err := os.Symlink(target, link); err != nil {
308 t.Fatal(err)
309 }
310 store, _ := NewRemoteHostStore(link)
311 if _, err := store.Load(); !errors.Is(err, ErrRemoteHostStoreUnsafe) {
312 t.Fatalf("Load symlink error = %v, want ErrRemoteHostStoreUnsafe", err)
313 }
314 }
315
316 func TestNewRemoteHostEntryUsesIndependent256BitIdentity(t *testing.T) {
317 first, err := NewRemoteHostEntry("one", "One")
318 if err != nil {
319 t.Fatal(err)
320 }
321 second, err := NewRemoteHostEntry("two", "Two")
322 if err != nil {
323 t.Fatal(err)
324 }
325 if first.ClientInstanceID == second.ClientInstanceID {
326 t.Fatal("independent Host entries reused clientInstanceId")
327 }
328 for _, id := range []string{first.ClientInstanceID, second.ClientInstanceID} {
329 if !strings.HasPrefix(id, "desktop_") || len(strings.TrimPrefix(id, "desktop_")) != 64 {
330 t.Fatalf("clientInstanceId %q is not a 256-bit opaque identity", id)
331 }
332 }
333 if first.ID == second.ID || !strings.HasPrefix(first.ID, "host_") || len(strings.TrimPrefix(first.ID, "host_")) != 64 {
334 t.Fatalf("entry identities are not independent 256-bit values: %q %q", first.ID, second.ID)
335 }
336 }
337
338 func TestRemoteHostStoreStableIDSurvivesAliasRename(t *testing.T) {
339 store, err := NewRemoteHostStore(filepath.Join(t.TempDir(), "hosts.json"))
340 if err != nil {
341 t.Fatal(err)
342 }
343 entry, _ := NewRemoteHostEntry("old-alias", "Host")
344 if err := store.Upsert(entry); err != nil {
345 t.Fatal(err)
346 }
347 entry.Alias = "new-alias"
348 if err := store.Upsert(entry); err != nil {
349 t.Fatal(err)
350 }
351 loaded, ok, err := store.Get(entry.ID)
352 if err != nil || !ok {
353 t.Fatalf("Get = %#v, %v, %v", loaded, ok, err)
354 }
355 if loaded.ID != entry.ID || loaded.Alias != "new-alias" || loaded.ClientInstanceID != entry.ClientInstanceID {
356 t.Fatalf("renamed entry = %#v", loaded)
357 }
358 hosts, _ := store.Load()
359 if len(hosts) != 1 {
360 t.Fatalf("alias rename created %d records", len(hosts))
361 }
362 }
363
364 func TestRemoteHostStoreValidatesOptionalSSHConfigPath(t *testing.T) {
365 store, err := NewRemoteHostStore(filepath.Join(t.TempDir(), "hosts.json"))
366 if err != nil {
367 t.Fatal(err)
368 }
369 entry, _ := NewRemoteHostEntry("host", "Host")
370 dir := t.TempDir()
371 unclean := dir + string(os.PathSeparator) + ".." + string(os.PathSeparator) + "unclean"
372 for _, invalid := range []string{"relative/config", unclean, "/tmp/config\nProxyCommand evil"} {
373 entry.SSHConfigPath = invalid
374 if err := store.Upsert(entry); err == nil {
375 t.Errorf("sshConfigPath %q unexpectedly accepted", invalid)
376 }
377 }
378 entry.SSHConfigPath = filepath.Join(t.TempDir(), "ssh config")
379 if err := store.Upsert(entry); err != nil {
380 t.Fatalf("absolute clean sshConfigPath: %v", err)
381 }
382 }
383
383 lines GO