detached db35bc9

fix(config): keep remote settings scalar-only

Remove host-list persistence from config so [remote] only accepts
enabled/remember, and fail closed on leftover hosts assignments.

Signed-off-by: brice <b@bnema.dev>
commit
db35bc96f93df699b40c2708ae2e2ede7b93b241
author
brice · · 2026-07-30 07:25 UTC
committer
brice · · 2026-07-30 07:26 UTC
parent
a3a5b69
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +61 -596
M docs/configuration.md +13 -1
@@ -75,9 +75,21 @@ code.toggle-floating-pane = FLT
7575 code.rename-session = RNS
7676 code.rename-tab = RNT
7777 code.detach = DET
78+
79+[remote]
80+enabled = true
81+remember = true
7882 ```
7983
80-Invalid values log a warning and resolve that setting to its default on both initial load and reload.
84+Invalid values log a warning and resolve that setting to its default on both initial load and reload. A leftover `[remote] hosts = ...` assignment fails closed as unsupported; host lists are not stored in config.
85+
86+## Remote
87+
88+The optional `[remote]` section accepts only scalar booleans: `enabled` and `remember` (TOML-style `true`/`false`). Both default to `true`.
89+
90+`enabled = false` is a kill-switch for unified remote listing and host management. Direct `vev attach user@host[:session]` still works. `remember = false` stops learning hosts on successful attach.
91+
92+Host lists are not part of config. A `hosts` key under `[remote]` is a hard load error. Unrecognized remote keys log an `unknown key` warning. An unrecognized section header logs an `unknown section` warning and ignores its keys until the next section header.
8193
8294 ## Logs and durable state
8395
@@ -75,9 +75,21 @@ code.toggle-floating-pane = FLT
75 code.rename-session = RNS75 code.rename-session = RNS
76 code.rename-tab = RNT76 code.rename-tab = RNT
77 code.detach = DET77 code.detach = DET
78+
79+[remote]
80+enabled = true
81+remember = true
78 ```82 ```
79 83
80-Invalid values log a warning and resolve that setting to its default on both initial load and reload.84+Invalid values log a warning and resolve that setting to its default on both initial load and reload. A leftover `[remote] hosts = ...` assignment fails closed as unsupported; host lists are not stored in config.
85+
86+## Remote
87+
88+The optional `[remote]` section accepts only scalar booleans: `enabled` and `remember` (TOML-style `true`/`false`). Both default to `true`.
89+
90+`enabled = false` is a kill-switch for unified remote listing and host management. Direct `vev attach user@host[:session]` still works. `remember = false` stops learning hosts on successful attach.
91+
92+Host lists are not part of config. A `hosts` key under `[remote]` is a hard load error. Unrecognized remote keys log an `unknown key` warning. An unrecognized section header logs an `unknown section` warning and ignores its keys until the next section header.
81 93
82 ## Logs and durable state94 ## Logs and durable state
83 95
M internal/adapters/config/config.go +7 -2
@@ -27,7 +27,8 @@ var (
2727 // Parse reads vev's flat action = value config format. Duplicate action keys
2828 // are accepted with a warning; the last value wins while first-seen action order
2929 // is preserved for binding conflict resolution. An optional [remote] section
30-// accepts enabled/remember/hosts with TOML-style true/false values.
30+// accepts enabled/remember with TOML-style true/false values. A leftover hosts
31+// assignment fails closed as unsupported.
3132 func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
3233 cfg := domain.Defaults()
3334 var warnings []domain.Warning
@@ -81,8 +82,12 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
8182 }
8283 if section == "remote" {
8384 var remoteWarnings []domain.Warning
84- cfg.Remote, remoteWarnings = parseRemoteKey(cfg.Remote, seenRemoteKeys, key, value, lineNo)
85+ var remoteErr error
86+ cfg.Remote, remoteWarnings, remoteErr = parseRemoteKey(cfg.Remote, seenRemoteKeys, key, value, lineNo)
8587 warnings = append(warnings, remoteWarnings...)
88+ if remoteErr != nil {
89+ return cfg, warnings, remoteErr
90+ }
8691 continue
8792 }
8893
@@ -27,7 +27,8 @@ var (
27 // Parse reads vev's flat action = value config format. Duplicate action keys27 // Parse reads vev's flat action = value config format. Duplicate action keys
28 // are accepted with a warning; the last value wins while first-seen action order28 // are accepted with a warning; the last value wins while first-seen action order
29 // is preserved for binding conflict resolution. An optional [remote] section29 // is preserved for binding conflict resolution. An optional [remote] section
30-// accepts enabled/remember/hosts with TOML-style true/false values.30+// accepts enabled/remember with TOML-style true/false values. A leftover hosts
31+// assignment fails closed as unsupported.
31 func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {32 func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
32 cfg := domain.Defaults()33 cfg := domain.Defaults()
33 var warnings []domain.Warning34 var warnings []domain.Warning
@@ -81,8 +82,12 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
81 }82 }
82 if section == "remote" {83 if section == "remote" {
83 var remoteWarnings []domain.Warning84 var remoteWarnings []domain.Warning
84- cfg.Remote, remoteWarnings = parseRemoteKey(cfg.Remote, seenRemoteKeys, key, value, lineNo)85+ var remoteErr error
86+ cfg.Remote, remoteWarnings, remoteErr = parseRemoteKey(cfg.Remote, seenRemoteKeys, key, value, lineNo)
85 warnings = append(warnings, remoteWarnings...)87 warnings = append(warnings, remoteWarnings...)
88+ if remoteErr != nil {
89+ return cfg, warnings, remoteErr
90+ }
86 continue91 continue
87 }92 }
88 93
M internal/adapters/config/config_test.go +1 -1
@@ -358,7 +358,7 @@ func TestParse(t *testing.T) {
358358 if !tt.want.Palette.AnchorSet {
359359 tt.want.Palette = domain.Defaults().Palette
360360 }
361- if tt.want.Remote.Hosts == nil {
361+ if tt.want.Remote == (domain.RemoteConfig{}) {
362362 tt.want.Remote = domain.Defaults().Remote
363363 }
364364 got, warnings, err := Parse(strings.NewReader(tt.input))
@@ -358,7 +358,7 @@ func TestParse(t *testing.T) {
358 if !tt.want.Palette.AnchorSet {358 if !tt.want.Palette.AnchorSet {
359 tt.want.Palette = domain.Defaults().Palette359 tt.want.Palette = domain.Defaults().Palette
360 }360 }
361- if tt.want.Remote.Hosts == nil {361+ if tt.want.Remote == (domain.RemoteConfig{}) {
362 tt.want.Remote = domain.Defaults().Remote362 tt.want.Remote = domain.Defaults().Remote
363 }363 }
364 got, warnings, err := Parse(strings.NewReader(tt.input))364 got, warnings, err := Parse(strings.NewReader(tt.input))
M internal/adapters/config/remote.go +8 -286
@@ -1,27 +1,22 @@
11 package config
22
33 import (
4- "bytes"
5- "encoding/json"
6- "errors"
74 "fmt"
8- "os"
9- "path/filepath"
105 "strings"
11- "syscall"
126
137 "github.com/bnema/vev/internal/domain"
14- "github.com/bnema/vev/pkg/safedir"
158 )
169
17-func parseRemoteKey(remote domain.RemoteConfig, seen map[string]bool, key, value string, lineNo int) (domain.RemoteConfig, []domain.Warning) {
10+func parseRemoteKey(remote domain.RemoteConfig, seen map[string]bool, key, value string, lineNo int) (domain.RemoteConfig, []domain.Warning, error) {
1811 var warnings []domain.Warning
1912 switch key {
20- case "enabled", "remember", "hosts":
13+ case "enabled", "remember":
2114 warnings = warnDuplicateKey(warnings, seen, key, lineNo)
15+ case "hosts":
16+ return remote, warnings, fmt.Errorf("line %d: unsupported remote hosts assignment", lineNo)
2217 default:
2318 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("unknown key %q", key)})
24- return remote, warnings
19+ return remote, warnings, nil
2520 }
2621
2722 switch key {
@@ -29,25 +24,18 @@ func parseRemoteKey(remote domain.RemoteConfig, seen map[string]bool, key, value
2924 on, ok := parseTrueFalse(value)
3025 if !ok {
3126 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid enabled %q", value)})
32- return remote, warnings
27+ return remote, warnings, nil
3328 }
3429 remote.Enabled = on
3530 case "remember":
3631 on, ok := parseTrueFalse(value)
3732 if !ok {
3833 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid remember %q", value)})
39- return remote, warnings
34+ return remote, warnings, nil
4035 }
4136 remote.Remember = on
42- case "hosts":
43- hosts, hostWarnings := parseRemoteHosts(value, lineNo)
44- warnings = append(warnings, hostWarnings...)
45- if hosts == nil {
46- return remote, warnings
47- }
48- remote.Hosts = hosts
4937 }
50- return remote, warnings
38+ return remote, warnings, nil
5139 }
5240
5341 func parseTrueFalse(value string) (bool, bool) {
@@ -60,269 +48,3 @@ func parseTrueFalse(value string) (bool, bool) {
6048 return false, false
6149 }
6250 }
63-
64-func parseRemoteHosts(value string, lineNo int) ([]string, []domain.Warning) {
65- var raw json.RawMessage
66- if err := json.Unmarshal([]byte(value), &raw); err != nil {
67- return nil, []domain.Warning{{Line: lineNo, Msg: fmt.Sprintf("invalid hosts %q", value)}}
68- }
69- if len(raw) == 0 || raw[0] != '[' {
70- return nil, []domain.Warning{{Line: lineNo, Msg: fmt.Sprintf("invalid hosts %q", value)}}
71- }
72- var items []any
73- if err := json.Unmarshal(raw, &items); err != nil {
74- return nil, []domain.Warning{{Line: lineNo, Msg: fmt.Sprintf("invalid hosts %q", value)}}
75- }
76- out := make([]string, 0, len(items))
77- seen := make(map[string]struct{}, len(items))
78- var warnings []domain.Warning
79- ok := true
80- for _, item := range items {
81- host, isString := item.(string)
82- if !isString {
83- return nil, []domain.Warning{{Line: lineNo, Msg: fmt.Sprintf("invalid hosts %q", value)}}
84- }
85- if err := domain.ValidateRemoteHostTarget(host); err != nil {
86- warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid remote host %q: %v", host, err)})
87- ok = false
88- continue
89- }
90- if _, dup := seen[host]; dup {
91- warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate host %q", host)})
92- ok = false
93- continue
94- }
95- seen[host] = struct{}{}
96- out = append(out, host)
97- }
98- if !ok {
99- return nil, warnings
100- }
101- return out, nil
102-}
103-
104-// UpdateRemoteHosts creates or replaces the pinned hosts list in path's [remote]
105-// section while preserving unrelated config text. Writes use a private parent
106-// directory and same-directory temp + sync + rename + directory sync.
107-func UpdateRemoteHosts(path string, hosts []string) error {
108- hosts = domain.UniqueRemoteHostTargets(hosts)
109- for _, host := range hosts {
110- if err := domain.ValidateRemoteHostTarget(host); err != nil {
111- return err
112- }
113- }
114- if err := safedir.EnsurePrivate(filepath.Dir(path)); err != nil {
115- return err
116- }
117-
118- lockFile, err := acquireConfigLock(path)
119- if err != nil {
120- return err
121- }
122- defer func() {
123- _ = releaseConfigLock(lockFile)
124- }()
125-
126- original, err := os.ReadFile(path)
127- if err != nil && !errors.Is(err, os.ErrNotExist) {
128- return err
129- }
130- missing := errors.Is(err, os.ErrNotExist)
131-
132- hostsLine, err := formatRemoteHostsLine(hosts)
133- if err != nil {
134- return err
135- }
136-
137- var next []byte
138- if missing || len(bytes.TrimSpace(original)) == 0 {
139- next = []byte("[remote]\n" + hostsLine + "\n")
140- } else {
141- next = rewriteRemoteHosts(original, hostsLine)
142- }
143- return writeConfigAtomic(path, next)
144-}
145-
146-func formatRemoteHostsLine(hosts []string) (string, error) {
147- var b strings.Builder
148- b.WriteString("hosts = [")
149- for i, host := range hosts {
150- if i > 0 {
151- b.WriteString(", ")
152- }
153- encoded, err := json.Marshal(host)
154- if err != nil {
155- return "", err
156- }
157- b.Write(encoded)
158- }
159- b.WriteByte(']')
160- return b.String(), nil
161-}
162-
163-func rewriteRemoteHosts(original []byte, hostsLine string) []byte {
164- lines := splitKeepNewlines(original)
165- remoteIdx := -1
166- hostsIdx := -1
167- inRemote := false
168- for i, line := range lines {
169- trimmed := strings.TrimSpace(strings.TrimSuffix(strings.TrimSuffix(line, "\n"), "\r"))
170- if trimmed == "" || strings.HasPrefix(trimmed, "#") {
171- continue
172- }
173- trimmed = stripInlineComment(trimmed)
174- if matches := sectionPattern.FindStringSubmatch(trimmed); matches != nil {
175- inRemote = matches[1] == "remote"
176- if inRemote && remoteIdx < 0 {
177- remoteIdx = i
178- }
179- continue
180- }
181- if !inRemote {
182- continue
183- }
184- key, _, ok := strings.Cut(trimmed, "=")
185- if !ok {
186- continue
187- }
188- if strings.TrimSpace(key) == "hosts" {
189- hostsIdx = i
190- }
191- }
192-
193- ending := dominantLineEnding(original)
194-
195- switch {
196- case hostsIdx >= 0:
197- prefix := lines[hostsIdx]
198- nl := "\n"
199- if strings.HasSuffix(prefix, "\r\n") {
200- nl = "\r\n"
201- } else if !strings.HasSuffix(prefix, "\n") {
202- nl = ""
203- }
204- lines[hostsIdx] = hostsLine + nl
205- case remoteIdx >= 0:
206- insertAt := remoteIdx + 1
207- for insertAt < len(lines) {
208- trimmed := strings.TrimSpace(strings.TrimSuffix(strings.TrimSuffix(lines[insertAt], "\n"), "\r"))
209- if matches := sectionPattern.FindStringSubmatch(stripInlineComment(trimmed)); matches != nil {
210- break
211- }
212- insertAt++
213- }
214- hostsEntry := hostsLine + ending
215- lines = append(lines[:insertAt], append([]string{hostsEntry}, lines[insertAt:]...)...)
216- default:
217- if len(lines) > 0 {
218- last := lines[len(lines)-1]
219- if last != "" && !strings.HasSuffix(last, "\n") && !strings.HasSuffix(last, "\r\n") {
220- lines[len(lines)-1] = last + ending
221- }
222- }
223- lines = append(lines, "[remote]"+ending, hostsLine+ending)
224- }
225-
226- var b strings.Builder
227- for _, line := range lines {
228- b.WriteString(line)
229- }
230- return []byte(b.String())
231-}
232-
233-func dominantLineEnding(data []byte) string {
234- crlf := 0
235- lf := 0
236- for i := 0; i < len(data); i++ {
237- if data[i] != '\n' {
238- continue
239- }
240- if i > 0 && data[i-1] == '\r' {
241- crlf++
242- } else {
243- lf++
244- }
245- }
246- if crlf > lf {
247- return "\r\n"
248- }
249- return "\n"
250-}
251-
252-func acquireConfigLock(path string) (*os.File, error) {
253- file, err := os.OpenFile(path+".lock", os.O_RDWR|os.O_CREATE, 0o600)
254- if err != nil {
255- return nil, err
256- }
257- if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX); err != nil {
258- _ = file.Close()
259- return nil, err
260- }
261- return file, nil
262-}
263-
264-func releaseConfigLock(file *os.File) error {
265- if file == nil {
266- return nil
267- }
268- return errors.Join(syscall.Flock(int(file.Fd()), syscall.LOCK_UN), file.Close())
269-}
270-
271-func splitKeepNewlines(data []byte) []string {
272- if len(data) == 0 {
273- return nil
274- }
275- var lines []string
276- start := 0
277- for i := 0; i < len(data); i++ {
278- if data[i] == '\n' {
279- lines = append(lines, string(data[start:i+1]))
280- start = i + 1
281- }
282- }
283- if start < len(data) {
284- lines = append(lines, string(data[start:]))
285- }
286- return lines
287-}
288-
289-func writeConfigAtomic(path string, data []byte) error {
290- dir := filepath.Dir(path)
291- file, err := os.CreateTemp(dir, ".vev-config-*.tmp")
292- if err != nil {
293- return err
294- }
295- tmp := file.Name()
296- committed := false
297- defer func() {
298- if !committed {
299- _ = file.Close()
300- _ = os.Remove(tmp)
301- }
302- }()
303- if err := file.Chmod(0o600); err != nil {
304- return err
305- }
306- if _, err := file.Write(data); err != nil {
307- return err
308- }
309- if err := file.Sync(); err != nil {
310- return err
311- }
312- if err := file.Close(); err != nil {
313- return err
314- }
315- if err := os.Rename(tmp, path); err != nil {
316- return err
317- }
318- committed = true
319- return syncDir(dir)
320-}
321-
322-func syncDir(dir string) error {
323- file, err := os.Open(dir)
324- if err != nil {
325- return err
326- }
327- return errors.Join(file.Sync(), file.Close())
328-}
@@ -1,27 +1,22 @@
1 package config1 package config
2 2
3 import (3 import (
4- "bytes"
5- "encoding/json"
6- "errors"
7 "fmt"4 "fmt"
8- "os"
9- "path/filepath"
10 "strings"5 "strings"
11- "syscall"
12 6
13 "github.com/bnema/vev/internal/domain"7 "github.com/bnema/vev/internal/domain"
14- "github.com/bnema/vev/pkg/safedir"
15 )8 )
16 9
17-func parseRemoteKey(remote domain.RemoteConfig, seen map[string]bool, key, value string, lineNo int) (domain.RemoteConfig, []domain.Warning) {10+func parseRemoteKey(remote domain.RemoteConfig, seen map[string]bool, key, value string, lineNo int) (domain.RemoteConfig, []domain.Warning, error) {
18 var warnings []domain.Warning11 var warnings []domain.Warning
19 switch key {12 switch key {
20- case "enabled", "remember", "hosts":13+ case "enabled", "remember":
21 warnings = warnDuplicateKey(warnings, seen, key, lineNo)14 warnings = warnDuplicateKey(warnings, seen, key, lineNo)
15+ case "hosts":
16+ return remote, warnings, fmt.Errorf("line %d: unsupported remote hosts assignment", lineNo)
22 default:17 default:
23 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("unknown key %q", key)})18 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("unknown key %q", key)})
24- return remote, warnings19+ return remote, warnings, nil
25 }20 }
26 21
27 switch key {22 switch key {
@@ -29,25 +24,18 @@ func parseRemoteKey(remote domain.RemoteConfig, seen map[string]bool, key, value
29 on, ok := parseTrueFalse(value)24 on, ok := parseTrueFalse(value)
30 if !ok {25 if !ok {
31 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid enabled %q", value)})26 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid enabled %q", value)})
32- return remote, warnings27+ return remote, warnings, nil
33 }28 }
34 remote.Enabled = on29 remote.Enabled = on
35 case "remember":30 case "remember":
36 on, ok := parseTrueFalse(value)31 on, ok := parseTrueFalse(value)
37 if !ok {32 if !ok {
38 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid remember %q", value)})33 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid remember %q", value)})
39- return remote, warnings34+ return remote, warnings, nil
40 }35 }
41 remote.Remember = on36 remote.Remember = on
42- case "hosts":
43- hosts, hostWarnings := parseRemoteHosts(value, lineNo)
44- warnings = append(warnings, hostWarnings...)
45- if hosts == nil {
46- return remote, warnings
47- }
48- remote.Hosts = hosts
49 }37 }
50- return remote, warnings38+ return remote, warnings, nil
51 }39 }
52 40
53 func parseTrueFalse(value string) (bool, bool) {41 func parseTrueFalse(value string) (bool, bool) {
@@ -60,269 +48,3 @@ func parseTrueFalse(value string) (bool, bool) {
60 return false, false48 return false, false
61 }49 }
62 }50 }
63-
64-func parseRemoteHosts(value string, lineNo int) ([]string, []domain.Warning) {
65- var raw json.RawMessage
66- if err := json.Unmarshal([]byte(value), &raw); err != nil {
67- return nil, []domain.Warning{{Line: lineNo, Msg: fmt.Sprintf("invalid hosts %q", value)}}
68- }
69- if len(raw) == 0 || raw[0] != '[' {
70- return nil, []domain.Warning{{Line: lineNo, Msg: fmt.Sprintf("invalid hosts %q", value)}}
71- }
72- var items []any
73- if err := json.Unmarshal(raw, &items); err != nil {
74- return nil, []domain.Warning{{Line: lineNo, Msg: fmt.Sprintf("invalid hosts %q", value)}}
75- }
76- out := make([]string, 0, len(items))
77- seen := make(map[string]struct{}, len(items))
78- var warnings []domain.Warning
79- ok := true
80- for _, item := range items {
81- host, isString := item.(string)
82- if !isString {
83- return nil, []domain.Warning{{Line: lineNo, Msg: fmt.Sprintf("invalid hosts %q", value)}}
84- }
85- if err := domain.ValidateRemoteHostTarget(host); err != nil {
86- warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid remote host %q: %v", host, err)})
87- ok = false
88- continue
89- }
90- if _, dup := seen[host]; dup {
91- warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate host %q", host)})
92- ok = false
93- continue
94- }
95- seen[host] = struct{}{}
96- out = append(out, host)
97- }
98- if !ok {
99- return nil, warnings
100- }
101- return out, nil
102-}
103-
104-// UpdateRemoteHosts creates or replaces the pinned hosts list in path's [remote]
105-// section while preserving unrelated config text. Writes use a private parent
106-// directory and same-directory temp + sync + rename + directory sync.
107-func UpdateRemoteHosts(path string, hosts []string) error {
108- hosts = domain.UniqueRemoteHostTargets(hosts)
109- for _, host := range hosts {
110- if err := domain.ValidateRemoteHostTarget(host); err != nil {
111- return err
112- }
113- }
114- if err := safedir.EnsurePrivate(filepath.Dir(path)); err != nil {
115- return err
116- }
117-
118- lockFile, err := acquireConfigLock(path)
119- if err != nil {
120- return err
121- }
122- defer func() {
123- _ = releaseConfigLock(lockFile)
124- }()
125-
126- original, err := os.ReadFile(path)
127- if err != nil && !errors.Is(err, os.ErrNotExist) {
128- return err
129- }
130- missing := errors.Is(err, os.ErrNotExist)
131-
132- hostsLine, err := formatRemoteHostsLine(hosts)
133- if err != nil {
134- return err
135- }
136-
137- var next []byte
138- if missing || len(bytes.TrimSpace(original)) == 0 {
139- next = []byte("[remote]\n" + hostsLine + "\n")
140- } else {
141- next = rewriteRemoteHosts(original, hostsLine)
142- }
143- return writeConfigAtomic(path, next)
144-}
145-
146-func formatRemoteHostsLine(hosts []string) (string, error) {
147- var b strings.Builder
148- b.WriteString("hosts = [")
149- for i, host := range hosts {
150- if i > 0 {
151- b.WriteString(", ")
152- }
153- encoded, err := json.Marshal(host)
154- if err != nil {
155- return "", err
156- }
157- b.Write(encoded)
158- }
159- b.WriteByte(']')
160- return b.String(), nil
161-}
162-
163-func rewriteRemoteHosts(original []byte, hostsLine string) []byte {
164- lines := splitKeepNewlines(original)
165- remoteIdx := -1
166- hostsIdx := -1
167- inRemote := false
168- for i, line := range lines {
169- trimmed := strings.TrimSpace(strings.TrimSuffix(strings.TrimSuffix(line, "\n"), "\r"))
170- if trimmed == "" || strings.HasPrefix(trimmed, "#") {
171- continue
172- }
173- trimmed = stripInlineComment(trimmed)
174- if matches := sectionPattern.FindStringSubmatch(trimmed); matches != nil {
175- inRemote = matches[1] == "remote"
176- if inRemote && remoteIdx < 0 {
177- remoteIdx = i
178- }
179- continue
180- }
181- if !inRemote {
182- continue
183- }
184- key, _, ok := strings.Cut(trimmed, "=")
185- if !ok {
186- continue
187- }
188- if strings.TrimSpace(key) == "hosts" {
189- hostsIdx = i
190- }
191- }
192-
193- ending := dominantLineEnding(original)
194-
195- switch {
196- case hostsIdx >= 0:
197- prefix := lines[hostsIdx]
198- nl := "\n"
199- if strings.HasSuffix(prefix, "\r\n") {
200- nl = "\r\n"
201- } else if !strings.HasSuffix(prefix, "\n") {
202- nl = ""
203- }
204- lines[hostsIdx] = hostsLine + nl
205- case remoteIdx >= 0:
206- insertAt := remoteIdx + 1
207- for insertAt < len(lines) {
208- trimmed := strings.TrimSpace(strings.TrimSuffix(strings.TrimSuffix(lines[insertAt], "\n"), "\r"))
209- if matches := sectionPattern.FindStringSubmatch(stripInlineComment(trimmed)); matches != nil {
210- break
211- }
212- insertAt++
213- }
214- hostsEntry := hostsLine + ending
215- lines = append(lines[:insertAt], append([]string{hostsEntry}, lines[insertAt:]...)...)
216- default:
217- if len(lines) > 0 {
218- last := lines[len(lines)-1]
219- if last != "" && !strings.HasSuffix(last, "\n") && !strings.HasSuffix(last, "\r\n") {
220- lines[len(lines)-1] = last + ending
221- }
222- }
223- lines = append(lines, "[remote]"+ending, hostsLine+ending)
224- }
225-
226- var b strings.Builder
227- for _, line := range lines {
228- b.WriteString(line)
229- }
230- return []byte(b.String())
231-}
232-
233-func dominantLineEnding(data []byte) string {
234- crlf := 0
235- lf := 0
236- for i := 0; i < len(data); i++ {
237- if data[i] != '\n' {
238- continue
239- }
240- if i > 0 && data[i-1] == '\r' {
241- crlf++
242- } else {
243- lf++
244- }
245- }
246- if crlf > lf {
247- return "\r\n"
248- }
249- return "\n"
250-}
251-
252-func acquireConfigLock(path string) (*os.File, error) {
253- file, err := os.OpenFile(path+".lock", os.O_RDWR|os.O_CREATE, 0o600)
254- if err != nil {
255- return nil, err
256- }
257- if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX); err != nil {
258- _ = file.Close()
259- return nil, err
260- }
261- return file, nil
262-}
263-
264-func releaseConfigLock(file *os.File) error {
265- if file == nil {
266- return nil
267- }
268- return errors.Join(syscall.Flock(int(file.Fd()), syscall.LOCK_UN), file.Close())
269-}
270-
271-func splitKeepNewlines(data []byte) []string {
272- if len(data) == 0 {
273- return nil
274- }
275- var lines []string
276- start := 0
277- for i := 0; i < len(data); i++ {
278- if data[i] == '\n' {
279- lines = append(lines, string(data[start:i+1]))
280- start = i + 1
281- }
282- }
283- if start < len(data) {
284- lines = append(lines, string(data[start:]))
285- }
286- return lines
287-}
288-
289-func writeConfigAtomic(path string, data []byte) error {
290- dir := filepath.Dir(path)
291- file, err := os.CreateTemp(dir, ".vev-config-*.tmp")
292- if err != nil {
293- return err
294- }
295- tmp := file.Name()
296- committed := false
297- defer func() {
298- if !committed {
299- _ = file.Close()
300- _ = os.Remove(tmp)
301- }
302- }()
303- if err := file.Chmod(0o600); err != nil {
304- return err
305- }
306- if _, err := file.Write(data); err != nil {
307- return err
308- }
309- if err := file.Sync(); err != nil {
310- return err
311- }
312- if err := file.Close(); err != nil {
313- return err
314- }
315- if err := os.Rename(tmp, path); err != nil {
316- return err
317- }
318- committed = true
319- return syncDir(dir)
320-}
321-
322-func syncDir(dir string) error {
323- file, err := os.Open(dir)
324- if err != nil {
325- return err
326- }
327- return errors.Join(file.Sync(), file.Close())
328-}
M internal/adapters/config/remote_test.go +29 -298
@@ -1,15 +1,10 @@
11 package config
22
33 import (
4- "os"
5- "path/filepath"
6- "reflect"
7- "strconv"
84 "strings"
95 "testing"
106
117 "github.com/bnema/vev/internal/domain"
12- "github.com/bnema/vev/pkg/safedir"
138 "github.com/stretchr/testify/require"
149 )
1510
@@ -20,6 +15,7 @@ func TestParseRemote(t *testing.T) {
2015 name string
2116 input string
2217 want domain.RemoteConfig
18+ wantErr string
2319 wantWarnings []domain.Warning
2420 wantTheme domain.ThemeMode
2521 checkTheme bool
@@ -28,19 +24,17 @@ func TestParseRemote(t *testing.T) {
2824 }{
2925 {
3026 name: "defaults when section absent",
31- want: domain.RemoteConfig{Enabled: true, Remember: true, Hosts: []string{}},
27+ want: domain.RemoteConfig{Enabled: true, Remember: true},
3228 },
3329 {
3430 name: "complete remote block",
3531 input: `[remote]
3632 enabled = true
3733 remember = true
38-hosts = ["arch", "build@mule"]
3934 `,
4035 want: domain.RemoteConfig{
4136 Enabled: true,
4237 Remember: true,
43- Hosts: []string{"arch", "build@mule"},
4438 },
4539 },
4640 {
@@ -48,14 +42,14 @@ hosts = ["arch", "build@mule"]
4842 input: `[remote]
4943 enabled = false
5044 `,
51- want: domain.RemoteConfig{Enabled: false, Remember: true, Hosts: []string{}},
45+ want: domain.RemoteConfig{Enabled: false, Remember: true},
5246 },
5347 {
5448 name: "remember false",
5549 input: `[remote]
5650 remember = false
5751 `,
58- want: domain.RemoteConfig{Enabled: true, Remember: false, Hosts: []string{}},
52+ want: domain.RemoteConfig{Enabled: true, Remember: false},
5953 },
6054 {
6155 name: "legacy on off rejected and defaults retained",
@@ -63,19 +57,12 @@ remember = false
6357 enabled = off
6458 remember = on
6559 `,
66- want: domain.RemoteConfig{Enabled: true, Remember: true, Hosts: []string{}},
60+ want: domain.RemoteConfig{Enabled: true, Remember: true},
6761 wantWarnings: []domain.Warning{
6862 {Line: 2, Msg: `invalid enabled "off"`},
6963 {Line: 3, Msg: `invalid remember "on"`},
7064 },
7165 },
72- {
73- name: "empty hosts array",
74- input: `[remote]
75-hosts = []
76-`,
77- want: domain.RemoteConfig{Enabled: true, Remember: true, Hosts: []string{}},
78- },
7966 {
8067 name: "duplicate keys warn and last valid wins",
8168 input: `[remote]
@@ -83,14 +70,11 @@ enabled = false
8370 enabled = true
8471 remember = false
8572 remember = true
86-hosts = ["arch"]
87-hosts = ["mule"]
8873 `,
89- want: domain.RemoteConfig{Enabled: true, Remember: true, Hosts: []string{"mule"}},
74+ want: domain.RemoteConfig{Enabled: true, Remember: true},
9075 wantWarnings: []domain.Warning{
9176 {Line: 3, Msg: `duplicate key "enabled"`},
9277 {Line: 5, Msg: `duplicate key "remember"`},
93- {Line: 7, Msg: `duplicate key "hosts"`},
9478 },
9579 },
9680 {
@@ -99,64 +83,41 @@ hosts = ["mule"]
9983 enabled = yes
10084 remember = True
10185 `,
102- want: domain.RemoteConfig{Enabled: true, Remember: true, Hosts: []string{}},
86+ want: domain.RemoteConfig{Enabled: true, Remember: true},
10387 wantWarnings: []domain.Warning{
10488 {Line: 2, Msg: `invalid enabled "yes"`},
10589 {Line: 3, Msg: `invalid remember "True"`},
10690 },
10791 },
10892 {
109- name: "invalid hosts array retains default",
110- input: `[remote]
111-hosts = [1, "arch"]
112-`,
113- want: domain.RemoteConfig{Enabled: true, Remember: true, Hosts: []string{}},
114- wantWarnings: []domain.Warning{
115- {Line: 2, Msg: `invalid hosts "[1, \"arch\"]"`},
116- },
117- },
118- {
119- name: "malformed hosts array retains default",
93+ name: "hosts assignment fails closed",
12094 input: `[remote]
121-hosts = not-an-array
95+hosts = ["arch", "build@mule"]
12296 `,
123- want: domain.RemoteConfig{Enabled: true, Remember: true, Hosts: []string{}},
124- wantWarnings: []domain.Warning{
125- {Line: 2, Msg: `invalid hosts "not-an-array"`},
126- },
97+ wantErr: `line 2: unsupported remote hosts assignment`,
12798 },
12899 {
129- name: "null hosts retains default",
100+ name: "empty hosts array fails closed",
130101 input: `[remote]
131-hosts = null
102+hosts = []
132103 `,
133- want: domain.RemoteConfig{Enabled: true, Remember: true, Hosts: []string{}},
134- wantWarnings: []domain.Warning{
135- {Line: 2, Msg: `invalid hosts "null"`},
136- },
104+ wantErr: `line 2: unsupported remote hosts assignment`,
137105 },
138106 {
139- name: "invalid and duplicate host entries retain default",
107+ name: "malformed hosts assignment fails closed",
140108 input: `[remote]
141-hosts = ["arch", "arch", " bad", "ok"]
109+hosts = not-an-array
142110 `,
143- want: domain.RemoteConfig{Enabled: true, Remember: true, Hosts: []string{}},
144- wantWarnings: []domain.Warning{
145- {Line: 2, Msg: `duplicate host "arch"`},
146- {Line: 2, Msg: `invalid remote host " bad": remote host target " bad" has surrounding whitespace`},
147- },
111+ wantErr: `line 2: unsupported remote hosts assignment`,
148112 },
149113 {
150- name: "invalid hosts assignment retains previous value",
151- input: `[remote]
114+ name: "hosts cannot fall through as a binding",
115+ input: `theme = light
116+[remote]
152117 hosts = ["arch"]
153-hosts = ["arch", " bad", "ok"]
118+new-tab = alt+t
154119 `,
155- want: domain.RemoteConfig{Enabled: true, Remember: true, Hosts: []string{"arch"}},
156- wantWarnings: []domain.Warning{
157- {Line: 3, Msg: `duplicate key "hosts"`},
158- {Line: 3, Msg: `invalid remote host " bad": remote host target " bad" has surrounding whitespace`},
159- },
120+ wantErr: `line 3: unsupported remote hosts assignment`,
160121 },
161122 {
162123 name: "unknown remote key warns",
@@ -164,7 +125,7 @@ hosts = ["arch", " bad", "ok"]
164125 proxy = true
165126 enabled = false
166127 `,
167- want: domain.RemoteConfig{Enabled: false, Remember: true, Hosts: []string{}},
128+ want: domain.RemoteConfig{Enabled: false, Remember: true},
168129 wantWarnings: []domain.Warning{
169130 {Line: 2, Msg: `unknown key "proxy"`},
170131 },
@@ -177,7 +138,7 @@ enabled = false
177138 [remote]
178139 enabled = false
179140 `,
180- want: domain.RemoteConfig{Enabled: false, Remember: true, Hosts: []string{}},
141+ want: domain.RemoteConfig{Enabled: false, Remember: true},
181142 wantWarnings: []domain.Warning{
182143 {Line: 2, Msg: `unknown section "other"`},
183144 },
@@ -189,9 +150,9 @@ enabled = false
189150 input: `theme = light
190151 new-tab = alt+t
191152 [remote]
192-hosts = ["arch"]
153+enabled = true
193154 `,
194- want: domain.RemoteConfig{Enabled: true, Remember: true, Hosts: []string{"arch"}},
155+ want: domain.RemoteConfig{Enabled: true, Remember: true},
195156 wantTheme: domain.ThemeLight,
196157 checkTheme: true,
197158 wantBindingEntries: []domain.ConfigEntry{{Key: "new-tab", Value: "alt+t"}},
@@ -204,6 +165,10 @@ hosts = ["arch"]
204165 t.Parallel()
205166
206167 cfg, warnings, err := Parse(strings.NewReader(tt.input))
168+ if tt.wantErr != "" {
169+ require.EqualError(t, err, tt.wantErr)
170+ return
171+ }
207172 require.NoError(t, err)
208173 require.Equal(t, tt.want, cfg.Remote)
209174 require.Equal(t, tt.wantWarnings, warnings)
@@ -216,237 +181,3 @@ hosts = ["arch"]
216181 })
217182 }
218183 }
219-
220-func TestUpdateRemoteHosts(t *testing.T) {
221- t.Parallel()
222-
223- privateConfigPath := func(t *testing.T) string {
224- t.Helper()
225- return filepath.Join(t.TempDir(), "vev", "config")
226- }
227-
228- t.Run("creates missing config", func(t *testing.T) {
229- t.Parallel()
230- path := privateConfigPath(t)
231-
232- require.NoError(t, UpdateRemoteHosts(path, []string{"arch", "build@mule"}))
233-
234- data, err := os.ReadFile(path)
235- require.NoError(t, err)
236- require.Equal(t, "[remote]\nhosts = [\"arch\", \"build@mule\"]\n", string(data))
237- info, err := os.Stat(path)
238- require.NoError(t, err)
239- require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
240- })
241-
242- t.Run("replaces existing hosts and preserves unrelated text", func(t *testing.T) {
243- t.Parallel()
244- path := privateConfigPath(t)
245- require.NoError(t, safedir.EnsurePrivate(filepath.Dir(path)))
246- original := "# keep me\ntheme = dark\n[remote]\nenabled = false\nhosts = [\"old\"]\nremember = false\nnew-tab = alt+t\n"
247- require.NoError(t, os.WriteFile(path, []byte(original), 0o600))
248-
249- require.NoError(t, UpdateRemoteHosts(path, []string{"arch", "arch", "mule"}))
250-
251- data, err := os.ReadFile(path)
252- require.NoError(t, err)
253- got := string(data)
254- require.Contains(t, got, "# keep me\n")
255- require.Contains(t, got, "theme = dark\n")
256- require.Contains(t, got, "enabled = false\n")
257- require.Contains(t, got, "remember = false\n")
258- require.Contains(t, got, "new-tab = alt+t\n")
259- require.Contains(t, got, "hosts = [\"arch\", \"mule\"]\n")
260- require.NotContains(t, got, "hosts = [\"old\"]")
261- info, err := os.Stat(path)
262- require.NoError(t, err)
263- require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
264- })
265-
266- t.Run("appends remote section when missing", func(t *testing.T) {
267- t.Parallel()
268- path := privateConfigPath(t)
269- require.NoError(t, safedir.EnsurePrivate(filepath.Dir(path)))
270- require.NoError(t, os.WriteFile(path, []byte("theme = light\n"), 0o600))
271-
272- require.NoError(t, UpdateRemoteHosts(path, []string{"arch"}))
273-
274- data, err := os.ReadFile(path)
275- require.NoError(t, err)
276- require.Equal(t, "theme = light\n[remote]\nhosts = [\"arch\"]\n", string(data))
277- })
278-
279- t.Run("appends hosts line when remote section lacks hosts", func(t *testing.T) {
280- t.Parallel()
281- path := privateConfigPath(t)
282- require.NoError(t, safedir.EnsurePrivate(filepath.Dir(path)))
283- require.NoError(t, os.WriteFile(path, []byte("[remote]\nenabled = true\n"), 0o600))
284-
285- require.NoError(t, UpdateRemoteHosts(path, []string{"arch"}))
286-
287- data, err := os.ReadFile(path)
288- require.NoError(t, err)
289- require.Equal(t, "[remote]\nenabled = true\nhosts = [\"arch\"]\n", string(data))
290- })
291-
292- t.Run("preserves CRLF line endings when appending hosts", func(t *testing.T) {
293- t.Parallel()
294- path := privateConfigPath(t)
295- require.NoError(t, safedir.EnsurePrivate(filepath.Dir(path)))
296- original := "[remote]\r\nenabled = true\r\n"
297- require.NoError(t, os.WriteFile(path, []byte(original), 0o600))
298-
299- require.NoError(t, UpdateRemoteHosts(path, []string{"arch"}))
300-
301- data, err := os.ReadFile(path)
302- require.NoError(t, err)
303- require.Equal(t, "[remote]\r\nenabled = true\r\nhosts = [\"arch\"]\r\n", string(data))
304- })
305-
306- t.Run("fails when existing path cannot be read", func(t *testing.T) {
307- t.Parallel()
308- path := privateConfigPath(t)
309- require.NoError(t, os.MkdirAll(path, 0o700))
310-
311- err := UpdateRemoteHosts(path, []string{"arch"})
312- require.Error(t, err)
313- })
314-
315- t.Run("rejects invalid targets without changing existing file", func(t *testing.T) {
316- t.Parallel()
317- path := privateConfigPath(t)
318- require.NoError(t, safedir.EnsurePrivate(filepath.Dir(path)))
319- original := "[remote]\nhosts = [\"arch\"]\n"
320- require.NoError(t, os.WriteFile(path, []byte(original), 0o600))
321-
322- invalid := []struct {
323- name string
324- hosts []string
325- }{
326- {name: "empty", hosts: []string{""}},
327- {name: "whitespace", hosts: []string{" bad"}},
328- {name: "control", hosts: []string{"good", "bad\x00"}},
329- {name: "mixed with valid", hosts: []string{"arch", " "}},
330- }
331- for _, tt := range invalid {
332- t.Run(tt.name, func(t *testing.T) {
333- err := UpdateRemoteHosts(path, tt.hosts)
334- require.Error(t, err)
335- data, readErr := os.ReadFile(path)
336- require.NoError(t, readErr)
337- require.Equal(t, original, string(data))
338- })
339- }
340- })
341-
342- t.Run("rejects invalid targets before creating parent directory", func(t *testing.T) {
343- t.Parallel()
344- path := privateConfigPath(t)
345-
346- err := UpdateRemoteHosts(path, []string{" "})
347- require.Error(t, err)
348- _, statErr := os.Stat(filepath.Dir(path))
349- require.ErrorIs(t, statErr, os.ErrNotExist)
350- })
351-
352- t.Run("replaces last effective hosts assignment", func(t *testing.T) {
353- t.Parallel()
354- path := privateConfigPath(t)
355- require.NoError(t, safedir.EnsurePrivate(filepath.Dir(path)))
356- original := "[remote]\nhosts = [\"first\"]\nenabled = true\nhosts = [\"second\"]\n"
357- require.NoError(t, os.WriteFile(path, []byte(original), 0o600))
358-
359- require.NoError(t, UpdateRemoteHosts(path, []string{"arch"}))
360-
361- data, err := os.ReadFile(path)
362- require.NoError(t, err)
363- got := string(data)
364- require.Contains(t, got, "hosts = [\"first\"]\n")
365- require.Contains(t, got, "hosts = [\"arch\"]\n")
366- require.NotContains(t, got, "hosts = [\"second\"]")
367- cfg, warnings, err := Parse(strings.NewReader(got))
368- require.NoError(t, err)
369- require.Equal(t, []string{"arch"}, cfg.Remote.Hosts)
370- require.Equal(t, []domain.Warning{{Line: 4, Msg: `duplicate key "hosts"`}}, warnings)
371- })
372-
373- t.Run("ignores stale shared tmp permissions", func(t *testing.T) {
374- t.Parallel()
375- path := privateConfigPath(t)
376- dir := filepath.Dir(path)
377- require.NoError(t, safedir.EnsurePrivate(dir))
378- staleTmp := path + ".tmp"
379- require.NoError(t, os.WriteFile(staleTmp, []byte("STALE"), 0o644))
380-
381- require.NoError(t, UpdateRemoteHosts(path, []string{"arch"}))
382-
383- info, err := os.Stat(path)
384- require.NoError(t, err)
385- require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
386- stale, err := os.ReadFile(staleTmp)
387- require.NoError(t, err)
388- require.Equal(t, "STALE", string(stale))
389- data, err := os.ReadFile(path)
390- require.NoError(t, err)
391- require.Equal(t, "[remote]\nhosts = [\"arch\"]\n", string(data))
392- entries, err := os.ReadDir(dir)
393- require.NoError(t, err)
394- for _, entry := range entries {
395- name := entry.Name()
396- if name == "config" || name == "config.tmp" {
397- continue
398- }
399- require.False(t, strings.Contains(name, ".tmp"), "leftover temp %q", name)
400- }
401- })
402-
403- t.Run("concurrent writes to same path leave complete parseable result", func(t *testing.T) {
404- t.Parallel()
405- path := privateConfigPath(t)
406- dir := filepath.Dir(path)
407- require.NoError(t, safedir.EnsurePrivate(dir))
408-
409- const n = 32
410- lists := make([][]string, n)
411- for i := 0; i < n; i++ {
412- lists[i] = []string{"host-a-" + strconv.Itoa(i), "host-b-" + strconv.Itoa(i)}
413- }
414- errCh := make(chan error, n)
415- for i := 0; i < n; i++ {
416- go func(i int) {
417- errCh <- UpdateRemoteHosts(path, lists[i])
418- }(i)
419- }
420- for i := 0; i < n; i++ {
421- require.NoError(t, <-errCh)
422- }
423-
424- data, err := os.ReadFile(path)
425- require.NoError(t, err)
426- cfg, warnings, err := Parse(strings.NewReader(string(data)))
427- require.NoError(t, err)
428- require.Empty(t, warnings)
429- matched := false
430- for _, list := range lists {
431- if reflect.DeepEqual(cfg.Remote.Hosts, list) {
432- matched = true
433- break
434- }
435- }
436- require.True(t, matched, "final hosts %#v must equal one complete submitted list", cfg.Remote.Hosts)
437-
438- info, err := os.Stat(path)
439- require.NoError(t, err)
440- require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
441-
442- entries, err := os.ReadDir(dir)
443- require.NoError(t, err)
444- for _, entry := range entries {
445- name := entry.Name()
446- if name == "config" {
447- continue
448- }
449- require.False(t, strings.Contains(name, "tmp"), "leftover temp %q", name)
450- }
451- })
452-}
@@ -1,15 +1,10 @@
1 package config1 package config
2 2
3 import (3 import (
4- "os"
5- "path/filepath"
6- "reflect"
7- "strconv"
8 "strings"4 "strings"
9 "testing"5 "testing"
10 6
11 "github.com/bnema/vev/internal/domain"7 "github.com/bnema/vev/internal/domain"
12- "github.com/bnema/vev/pkg/safedir"
13 "github.com/stretchr/testify/require"8 "github.com/stretchr/testify/require"
14 )9 )
15 10
@@ -20,6 +15,7 @@ func TestParseRemote(t *testing.T) {
20 name string15 name string
21 input string16 input string
22 want domain.RemoteConfig17 want domain.RemoteConfig
18+ wantErr string
23 wantWarnings []domain.Warning19 wantWarnings []domain.Warning
24 wantTheme domain.ThemeMode20 wantTheme domain.ThemeMode
25 checkTheme bool21 checkTheme bool
@@ -28,19 +24,17 @@ func TestParseRemote(t *testing.T) {
28 }{24 }{
29 {25 {
30 name: "defaults when section absent",26 name: "defaults when section absent",
31- want: domain.RemoteConfig{Enabled: true, Remember: true, Hosts: []string{}},27+ want: domain.RemoteConfig{Enabled: true, Remember: true},
32 },28 },
33 {29 {
34 name: "complete remote block",30 name: "complete remote block",
35 input: `[remote]31 input: `[remote]
36 enabled = true32 enabled = true
37 remember = true33 remember = true
38-hosts = ["arch", "build@mule"]
39 `,34 `,
40 want: domain.RemoteConfig{35 want: domain.RemoteConfig{
41 Enabled: true,36 Enabled: true,
42 Remember: true,37 Remember: true,
43- Hosts: []string{"arch", "build@mule"},
44 },38 },
45 },39 },
46 {40 {
@@ -48,14 +42,14 @@ hosts = ["arch", "build@mule"]
48 input: `[remote]42 input: `[remote]
49 enabled = false43 enabled = false
50 `,44 `,
51- want: domain.RemoteConfig{Enabled: false, Remember: true, Hosts: []string{}},45+ want: domain.RemoteConfig{Enabled: false, Remember: true},
52 },46 },
53 {47 {
54 name: "remember false",48 name: "remember false",
55 input: `[remote]49 input: `[remote]
56 remember = false50 remember = false
57 `,51 `,
58- want: domain.RemoteConfig{Enabled: true, Remember: false, Hosts: []string{}},52+ want: domain.RemoteConfig{Enabled: true, Remember: false},
59 },53 },
60 {54 {
61 name: "legacy on off rejected and defaults retained",55 name: "legacy on off rejected and defaults retained",
@@ -63,19 +57,12 @@ remember = false
63 enabled = off57 enabled = off
64 remember = on58 remember = on
65 `,59 `,
66- want: domain.RemoteConfig{Enabled: true, Remember: true, Hosts: []string{}},60+ want: domain.RemoteConfig{Enabled: true, Remember: true},
67 wantWarnings: []domain.Warning{61 wantWarnings: []domain.Warning{
68 {Line: 2, Msg: `invalid enabled "off"`},62 {Line: 2, Msg: `invalid enabled "off"`},
69 {Line: 3, Msg: `invalid remember "on"`},63 {Line: 3, Msg: `invalid remember "on"`},
70 },64 },
71 },65 },
72- {
73- name: "empty hosts array",
74- input: `[remote]
75-hosts = []
76-`,
77- want: domain.RemoteConfig{Enabled: true, Remember: true, Hosts: []string{}},
78- },
79 {66 {
80 name: "duplicate keys warn and last valid wins",67 name: "duplicate keys warn and last valid wins",
81 input: `[remote]68 input: `[remote]
@@ -83,14 +70,11 @@ enabled = false
83 enabled = true70 enabled = true
84 remember = false71 remember = false
85 remember = true72 remember = true
86-hosts = ["arch"]
87-hosts = ["mule"]
88 `,73 `,
89- want: domain.RemoteConfig{Enabled: true, Remember: true, Hosts: []string{"mule"}},74+ want: domain.RemoteConfig{Enabled: true, Remember: true},
90 wantWarnings: []domain.Warning{75 wantWarnings: []domain.Warning{
91 {Line: 3, Msg: `duplicate key "enabled"`},76 {Line: 3, Msg: `duplicate key "enabled"`},
92 {Line: 5, Msg: `duplicate key "remember"`},77 {Line: 5, Msg: `duplicate key "remember"`},
93- {Line: 7, Msg: `duplicate key "hosts"`},
94 },78 },
95 },79 },
96 {80 {
@@ -99,64 +83,41 @@ hosts = ["mule"]
99 enabled = yes83 enabled = yes
100 remember = True84 remember = True
101 `,85 `,
102- want: domain.RemoteConfig{Enabled: true, Remember: true, Hosts: []string{}},86+ want: domain.RemoteConfig{Enabled: true, Remember: true},
103 wantWarnings: []domain.Warning{87 wantWarnings: []domain.Warning{
104 {Line: 2, Msg: `invalid enabled "yes"`},88 {Line: 2, Msg: `invalid enabled "yes"`},
105 {Line: 3, Msg: `invalid remember "True"`},89 {Line: 3, Msg: `invalid remember "True"`},
106 },90 },
107 },91 },
108 {92 {
109- name: "invalid hosts array retains default",93+ name: "hosts assignment fails closed",
110- input: `[remote]
111-hosts = [1, "arch"]
112-`,
113- want: domain.RemoteConfig{Enabled: true, Remember: true, Hosts: []string{}},
114- wantWarnings: []domain.Warning{
115- {Line: 2, Msg: `invalid hosts "[1, \"arch\"]"`},
116- },
117- },
118- {
119- name: "malformed hosts array retains default",
120 input: `[remote]94 input: `[remote]
121-hosts = not-an-array95+hosts = ["arch", "build@mule"]
122 `,96 `,
123- want: domain.RemoteConfig{Enabled: true, Remember: true, Hosts: []string{}},97+ wantErr: `line 2: unsupported remote hosts assignment`,
124- wantWarnings: []domain.Warning{
125- {Line: 2, Msg: `invalid hosts "not-an-array"`},
126- },
127 },98 },
128 {99 {
129- name: "null hosts retains default",100+ name: "empty hosts array fails closed",
130 input: `[remote]101 input: `[remote]
131-hosts = null102+hosts = []
132 `,103 `,
133- want: domain.RemoteConfig{Enabled: true, Remember: true, Hosts: []string{}},104+ wantErr: `line 2: unsupported remote hosts assignment`,
134- wantWarnings: []domain.Warning{
135- {Line: 2, Msg: `invalid hosts "null"`},
136- },
137 },105 },
138 {106 {
139- name: "invalid and duplicate host entries retain default",107+ name: "malformed hosts assignment fails closed",
140 input: `[remote]108 input: `[remote]
141-hosts = ["arch", "arch", " bad", "ok"]109+hosts = not-an-array
142 `,110 `,
143- want: domain.RemoteConfig{Enabled: true, Remember: true, Hosts: []string{}},111+ wantErr: `line 2: unsupported remote hosts assignment`,
144- wantWarnings: []domain.Warning{
145- {Line: 2, Msg: `duplicate host "arch"`},
146- {Line: 2, Msg: `invalid remote host " bad": remote host target " bad" has surrounding whitespace`},
147- },
148 },112 },
149 {113 {
150- name: "invalid hosts assignment retains previous value",114+ name: "hosts cannot fall through as a binding",
151- input: `[remote]115+ input: `theme = light
116+[remote]
152 hosts = ["arch"]117 hosts = ["arch"]
153-hosts = ["arch", " bad", "ok"]118+new-tab = alt+t
154 `,119 `,
155- want: domain.RemoteConfig{Enabled: true, Remember: true, Hosts: []string{"arch"}},120+ wantErr: `line 3: unsupported remote hosts assignment`,
156- wantWarnings: []domain.Warning{
157- {Line: 3, Msg: `duplicate key "hosts"`},
158- {Line: 3, Msg: `invalid remote host " bad": remote host target " bad" has surrounding whitespace`},
159- },
160 },121 },
161 {122 {
162 name: "unknown remote key warns",123 name: "unknown remote key warns",
@@ -164,7 +125,7 @@ hosts = ["arch", " bad", "ok"]
164 proxy = true125 proxy = true
165 enabled = false126 enabled = false
166 `,127 `,
167- want: domain.RemoteConfig{Enabled: false, Remember: true, Hosts: []string{}},128+ want: domain.RemoteConfig{Enabled: false, Remember: true},
168 wantWarnings: []domain.Warning{129 wantWarnings: []domain.Warning{
169 {Line: 2, Msg: `unknown key "proxy"`},130 {Line: 2, Msg: `unknown key "proxy"`},
170 },131 },
@@ -177,7 +138,7 @@ enabled = false
177 [remote]138 [remote]
178 enabled = false139 enabled = false
179 `,140 `,
180- want: domain.RemoteConfig{Enabled: false, Remember: true, Hosts: []string{}},141+ want: domain.RemoteConfig{Enabled: false, Remember: true},
181 wantWarnings: []domain.Warning{142 wantWarnings: []domain.Warning{
182 {Line: 2, Msg: `unknown section "other"`},143 {Line: 2, Msg: `unknown section "other"`},
183 },144 },
@@ -189,9 +150,9 @@ enabled = false
189 input: `theme = light150 input: `theme = light
190 new-tab = alt+t151 new-tab = alt+t
191 [remote]152 [remote]
192-hosts = ["arch"]153+enabled = true
193 `,154 `,
194- want: domain.RemoteConfig{Enabled: true, Remember: true, Hosts: []string{"arch"}},155+ want: domain.RemoteConfig{Enabled: true, Remember: true},
195 wantTheme: domain.ThemeLight,156 wantTheme: domain.ThemeLight,
196 checkTheme: true,157 checkTheme: true,
197 wantBindingEntries: []domain.ConfigEntry{{Key: "new-tab", Value: "alt+t"}},158 wantBindingEntries: []domain.ConfigEntry{{Key: "new-tab", Value: "alt+t"}},
@@ -204,6 +165,10 @@ hosts = ["arch"]
204 t.Parallel()165 t.Parallel()
205 166
206 cfg, warnings, err := Parse(strings.NewReader(tt.input))167 cfg, warnings, err := Parse(strings.NewReader(tt.input))
168+ if tt.wantErr != "" {
169+ require.EqualError(t, err, tt.wantErr)
170+ return
171+ }
207 require.NoError(t, err)172 require.NoError(t, err)
208 require.Equal(t, tt.want, cfg.Remote)173 require.Equal(t, tt.want, cfg.Remote)
209 require.Equal(t, tt.wantWarnings, warnings)174 require.Equal(t, tt.wantWarnings, warnings)
@@ -216,237 +181,3 @@ hosts = ["arch"]
216 })181 })
217 }182 }
218 }183 }
219-
220-func TestUpdateRemoteHosts(t *testing.T) {
221- t.Parallel()
222-
223- privateConfigPath := func(t *testing.T) string {
224- t.Helper()
225- return filepath.Join(t.TempDir(), "vev", "config")
226- }
227-
228- t.Run("creates missing config", func(t *testing.T) {
229- t.Parallel()
230- path := privateConfigPath(t)
231-
232- require.NoError(t, UpdateRemoteHosts(path, []string{"arch", "build@mule"}))
233-
234- data, err := os.ReadFile(path)
235- require.NoError(t, err)
236- require.Equal(t, "[remote]\nhosts = [\"arch\", \"build@mule\"]\n", string(data))
237- info, err := os.Stat(path)
238- require.NoError(t, err)
239- require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
240- })
241-
242- t.Run("replaces existing hosts and preserves unrelated text", func(t *testing.T) {
243- t.Parallel()
244- path := privateConfigPath(t)
245- require.NoError(t, safedir.EnsurePrivate(filepath.Dir(path)))
246- original := "# keep me\ntheme = dark\n[remote]\nenabled = false\nhosts = [\"old\"]\nremember = false\nnew-tab = alt+t\n"
247- require.NoError(t, os.WriteFile(path, []byte(original), 0o600))
248-
249- require.NoError(t, UpdateRemoteHosts(path, []string{"arch", "arch", "mule"}))
250-
251- data, err := os.ReadFile(path)
252- require.NoError(t, err)
253- got := string(data)
254- require.Contains(t, got, "# keep me\n")
255- require.Contains(t, got, "theme = dark\n")
256- require.Contains(t, got, "enabled = false\n")
257- require.Contains(t, got, "remember = false\n")
258- require.Contains(t, got, "new-tab = alt+t\n")
259- require.Contains(t, got, "hosts = [\"arch\", \"mule\"]\n")
260- require.NotContains(t, got, "hosts = [\"old\"]")
261- info, err := os.Stat(path)
262- require.NoError(t, err)
263- require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
264- })
265-
266- t.Run("appends remote section when missing", func(t *testing.T) {
267- t.Parallel()
268- path := privateConfigPath(t)
269- require.NoError(t, safedir.EnsurePrivate(filepath.Dir(path)))
270- require.NoError(t, os.WriteFile(path, []byte("theme = light\n"), 0o600))
271-
272- require.NoError(t, UpdateRemoteHosts(path, []string{"arch"}))
273-
274- data, err := os.ReadFile(path)
275- require.NoError(t, err)
276- require.Equal(t, "theme = light\n[remote]\nhosts = [\"arch\"]\n", string(data))
277- })
278-
279- t.Run("appends hosts line when remote section lacks hosts", func(t *testing.T) {
280- t.Parallel()
281- path := privateConfigPath(t)
282- require.NoError(t, safedir.EnsurePrivate(filepath.Dir(path)))
283- require.NoError(t, os.WriteFile(path, []byte("[remote]\nenabled = true\n"), 0o600))
284-
285- require.NoError(t, UpdateRemoteHosts(path, []string{"arch"}))
286-
287- data, err := os.ReadFile(path)
288- require.NoError(t, err)
289- require.Equal(t, "[remote]\nenabled = true\nhosts = [\"arch\"]\n", string(data))
290- })
291-
292- t.Run("preserves CRLF line endings when appending hosts", func(t *testing.T) {
293- t.Parallel()
294- path := privateConfigPath(t)
295- require.NoError(t, safedir.EnsurePrivate(filepath.Dir(path)))
296- original := "[remote]\r\nenabled = true\r\n"
297- require.NoError(t, os.WriteFile(path, []byte(original), 0o600))
298-
299- require.NoError(t, UpdateRemoteHosts(path, []string{"arch"}))
300-
301- data, err := os.ReadFile(path)
302- require.NoError(t, err)
303- require.Equal(t, "[remote]\r\nenabled = true\r\nhosts = [\"arch\"]\r\n", string(data))
304- })
305-
306- t.Run("fails when existing path cannot be read", func(t *testing.T) {
307- t.Parallel()
308- path := privateConfigPath(t)
309- require.NoError(t, os.MkdirAll(path, 0o700))
310-
311- err := UpdateRemoteHosts(path, []string{"arch"})
312- require.Error(t, err)
313- })
314-
315- t.Run("rejects invalid targets without changing existing file", func(t *testing.T) {
316- t.Parallel()
317- path := privateConfigPath(t)
318- require.NoError(t, safedir.EnsurePrivate(filepath.Dir(path)))
319- original := "[remote]\nhosts = [\"arch\"]\n"
320- require.NoError(t, os.WriteFile(path, []byte(original), 0o600))
321-
322- invalid := []struct {
323- name string
324- hosts []string
325- }{
326- {name: "empty", hosts: []string{""}},
327- {name: "whitespace", hosts: []string{" bad"}},
328- {name: "control", hosts: []string{"good", "bad\x00"}},
329- {name: "mixed with valid", hosts: []string{"arch", " "}},
330- }
331- for _, tt := range invalid {
332- t.Run(tt.name, func(t *testing.T) {
333- err := UpdateRemoteHosts(path, tt.hosts)
334- require.Error(t, err)
335- data, readErr := os.ReadFile(path)
336- require.NoError(t, readErr)
337- require.Equal(t, original, string(data))
338- })
339- }
340- })
341-
342- t.Run("rejects invalid targets before creating parent directory", func(t *testing.T) {
343- t.Parallel()
344- path := privateConfigPath(t)
345-
346- err := UpdateRemoteHosts(path, []string{" "})
347- require.Error(t, err)
348- _, statErr := os.Stat(filepath.Dir(path))
349- require.ErrorIs(t, statErr, os.ErrNotExist)
350- })
351-
352- t.Run("replaces last effective hosts assignment", func(t *testing.T) {
353- t.Parallel()
354- path := privateConfigPath(t)
355- require.NoError(t, safedir.EnsurePrivate(filepath.Dir(path)))
356- original := "[remote]\nhosts = [\"first\"]\nenabled = true\nhosts = [\"second\"]\n"
357- require.NoError(t, os.WriteFile(path, []byte(original), 0o600))
358-
359- require.NoError(t, UpdateRemoteHosts(path, []string{"arch"}))
360-
361- data, err := os.ReadFile(path)
362- require.NoError(t, err)
363- got := string(data)
364- require.Contains(t, got, "hosts = [\"first\"]\n")
365- require.Contains(t, got, "hosts = [\"arch\"]\n")
366- require.NotContains(t, got, "hosts = [\"second\"]")
367- cfg, warnings, err := Parse(strings.NewReader(got))
368- require.NoError(t, err)
369- require.Equal(t, []string{"arch"}, cfg.Remote.Hosts)
370- require.Equal(t, []domain.Warning{{Line: 4, Msg: `duplicate key "hosts"`}}, warnings)
371- })
372-
373- t.Run("ignores stale shared tmp permissions", func(t *testing.T) {
374- t.Parallel()
375- path := privateConfigPath(t)
376- dir := filepath.Dir(path)
377- require.NoError(t, safedir.EnsurePrivate(dir))
378- staleTmp := path + ".tmp"
379- require.NoError(t, os.WriteFile(staleTmp, []byte("STALE"), 0o644))
380-
381- require.NoError(t, UpdateRemoteHosts(path, []string{"arch"}))
382-
383- info, err := os.Stat(path)
384- require.NoError(t, err)
385- require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
386- stale, err := os.ReadFile(staleTmp)
387- require.NoError(t, err)
388- require.Equal(t, "STALE", string(stale))
389- data, err := os.ReadFile(path)
390- require.NoError(t, err)
391- require.Equal(t, "[remote]\nhosts = [\"arch\"]\n", string(data))
392- entries, err := os.ReadDir(dir)
393- require.NoError(t, err)
394- for _, entry := range entries {
395- name := entry.Name()
396- if name == "config" || name == "config.tmp" {
397- continue
398- }
399- require.False(t, strings.Contains(name, ".tmp"), "leftover temp %q", name)
400- }
401- })
402-
403- t.Run("concurrent writes to same path leave complete parseable result", func(t *testing.T) {
404- t.Parallel()
405- path := privateConfigPath(t)
406- dir := filepath.Dir(path)
407- require.NoError(t, safedir.EnsurePrivate(dir))
408-
409- const n = 32
410- lists := make([][]string, n)
411- for i := 0; i < n; i++ {
412- lists[i] = []string{"host-a-" + strconv.Itoa(i), "host-b-" + strconv.Itoa(i)}
413- }
414- errCh := make(chan error, n)
415- for i := 0; i < n; i++ {
416- go func(i int) {
417- errCh <- UpdateRemoteHosts(path, lists[i])
418- }(i)
419- }
420- for i := 0; i < n; i++ {
421- require.NoError(t, <-errCh)
422- }
423-
424- data, err := os.ReadFile(path)
425- require.NoError(t, err)
426- cfg, warnings, err := Parse(strings.NewReader(string(data)))
427- require.NoError(t, err)
428- require.Empty(t, warnings)
429- matched := false
430- for _, list := range lists {
431- if reflect.DeepEqual(cfg.Remote.Hosts, list) {
432- matched = true
433- break
434- }
435- }
436- require.True(t, matched, "final hosts %#v must equal one complete submitted list", cfg.Remote.Hosts)
437-
438- info, err := os.Stat(path)
439- require.NoError(t, err)
440- require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
441-
442- entries, err := os.ReadDir(dir)
443- require.NoError(t, err)
444- for _, entry := range entries {
445- name := entry.Name()
446- if name == "config" {
447- continue
448- }
449- require.False(t, strings.Contains(name, "tmp"), "leftover temp %q", name)
450- }
451- })
452-}
M internal/domain/config.go +0 -1
@@ -139,7 +139,6 @@ func Defaults() Config {
139139 Remote: RemoteConfig{
140140 Enabled: true,
141141 Remember: true,
142- Hosts: []string{},
143142 },
144143 }
145144 }
@@ -139,7 +139,6 @@ func Defaults() Config {
139 Remote: RemoteConfig{139 Remote: RemoteConfig{
140 Enabled: true,140 Enabled: true,
141 Remember: true,141 Remember: true,
142- Hosts: []string{},
143 },142 },
144 }143 }
145 }144 }
M internal/domain/remote_host.go +1 -2
@@ -6,11 +6,10 @@ import (
66 "unicode"
77 )
88
9-// RemoteConfig contains user-configurable remote host settings.
9+// RemoteConfig contains user-configurable remote scalar settings.
1010 type RemoteConfig struct {
1111 Enabled bool
1212 Remember bool
13- Hosts []string
1413 }
1514
1615 // RemoteHost is a merged remote host entry with its source markers.
@@ -6,11 +6,10 @@ import (
6 "unicode"6 "unicode"
7 )7 )
8 8
9-// RemoteConfig contains user-configurable remote host settings.9+// RemoteConfig contains user-configurable remote scalar settings.
10 type RemoteConfig struct {10 type RemoteConfig struct {
11 Enabled bool11 Enabled bool
12 Remember bool12 Remember bool
13- Hosts []string
14 }13 }
15 14
16 // RemoteHost is a merged remote host entry with its source markers.15 // RemoteHost is a merged remote host entry with its source markers.
M internal/domain/remote_host_test.go +2 -5
@@ -80,10 +80,7 @@ func TestRemoteHostConfigDefaults(t *testing.T) {
8080 if !cfg.Remote.Remember {
8181 t.Fatal("Defaults().Remote.Remember = false, want true")
8282 }
83- if cfg.Remote.Hosts == nil {
84- t.Fatal("Defaults().Remote.Hosts = nil, want empty slice")
85- }
86- if len(cfg.Remote.Hosts) != 0 {
87- t.Fatalf("Defaults().Remote.Hosts = %v, want empty", cfg.Remote.Hosts)
83+ if cfg.Remote != (RemoteConfig{Enabled: true, Remember: true}) {
84+ t.Fatalf("Defaults().Remote = %#v, want Enabled=true Remember=true only", cfg.Remote)
8885 }
8986 }
@@ -80,10 +80,7 @@ func TestRemoteHostConfigDefaults(t *testing.T) {
80 if !cfg.Remote.Remember {80 if !cfg.Remote.Remember {
81 t.Fatal("Defaults().Remote.Remember = false, want true")81 t.Fatal("Defaults().Remote.Remember = false, want true")
82 }82 }
83- if cfg.Remote.Hosts == nil {83+ if cfg.Remote != (RemoteConfig{Enabled: true, Remember: true}) {
84- t.Fatal("Defaults().Remote.Hosts = nil, want empty slice")84+ t.Fatalf("Defaults().Remote = %#v, want Enabled=true Remember=true only", cfg.Remote)
85- }
86- if len(cfg.Remote.Hosts) != 0 {
87- t.Fatalf("Defaults().Remote.Hosts = %v, want empty", cfg.Remote.Hosts)
88 }85 }
89 }86 }

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 12

Tags 11

↑↓ moveenter openesc close