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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 75 | 75 | code.rename-session = RNS | |
| 76 | 76 | code.rename-tab = RNT | |
| 77 | 77 | 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 | 94 | ## Logs and durable state | |
| 83 | 95 | ||
| @@ -75,9 +75,21 @@ code.toggle-floating-pane = FLT | |||||
| 75 | code.rename-session = RNS | 75 | code.rename-session = RNS | ||
| 76 | code.rename-tab = RNT | 76 | code.rename-tab = RNT | ||
| 77 | code.detach = DET | 77 | 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 state | 94 | ## Logs and durable state | ||
| 83 | 95 | ||||
M internal/adapters/config/config.go +7 -2
| @@ -27,7 +27,8 @@ var ( | |||
| 27 | 27 | // Parse reads vev's flat action = value config format. Duplicate action keys | |
| 28 | 28 | // are accepted with a warning; the last value wins while first-seen action order | |
| 29 | 29 | // 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 | 32 | func Parse(r io.Reader) (domain.Config, []domain.Warning, error) { | |
| 32 | 33 | cfg := domain.Defaults() | |
| 33 | 34 | var warnings []domain.Warning | |
| @@ -81,8 +82,12 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) { | |||
| 81 | 82 | } | |
| 82 | 83 | if section == "remote" { | |
| 83 | 84 | 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 | 87 | warnings = append(warnings, remoteWarnings...) | |
| 88 | + | if remoteErr != nil { | |
| 89 | + | return cfg, warnings, remoteErr | |
| 90 | + | } | |
| 86 | 91 | continue | |
| 87 | 92 | } | |
| 88 | 93 | ||
| @@ -27,7 +27,8 @@ var ( | |||||
| 27 | // Parse reads vev's flat action = value config format. Duplicate action keys | 27 | // 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 order | 28 | // are accepted with a warning; the last value wins while first-seen action order | ||
| 29 | // is preserved for binding conflict resolution. An optional [remote] section | 29 | // 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.Warning | 34 | 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.Warning | 84 | 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 | continue | 91 | continue | ||
| 87 | } | 92 | } | ||
| 88 | 93 | ||||
M internal/adapters/config/config_test.go +1 -1
| @@ -358,7 +358,7 @@ func TestParse(t *testing.T) { | |||
| 358 | 358 | if !tt.want.Palette.AnchorSet { | |
| 359 | 359 | tt.want.Palette = domain.Defaults().Palette | |
| 360 | 360 | } | |
| 361 | - | if tt.want.Remote.Hosts == nil { | |
| 361 | + | if tt.want.Remote == (domain.RemoteConfig{}) { | |
| 362 | 362 | tt.want.Remote = domain.Defaults().Remote | |
| 363 | 363 | } | |
| 364 | 364 | 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().Palette | 359 | 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().Remote | 362 | 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 @@ | |||
| 1 | 1 | package config | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "bytes" | |
| 5 | - | "encoding/json" | |
| 6 | - | "errors" | |
| 7 | 4 | "fmt" | |
| 8 | - | "os" | |
| 9 | - | "path/filepath" | |
| 10 | 5 | "strings" | |
| 11 | - | "syscall" | |
| 12 | 6 | ||
| 13 | 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 | 11 | var warnings []domain.Warning | |
| 19 | 12 | switch key { | |
| 20 | - | case "enabled", "remember", "hosts": | |
| 13 | + | case "enabled", "remember": | |
| 21 | 14 | warnings = warnDuplicateKey(warnings, seen, key, lineNo) | |
| 15 | + | case "hosts": | |
| 16 | + | return remote, warnings, fmt.Errorf("line %d: unsupported remote hosts assignment", lineNo) | |
| 22 | 17 | default: | |
| 23 | 18 | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("unknown key %q", key)}) | |
| 24 | - | return remote, warnings | |
| 19 | + | return remote, warnings, nil | |
| 25 | 20 | } | |
| 26 | 21 | ||
| 27 | 22 | switch key { | |
| @@ -29,25 +24,18 @@ func parseRemoteKey(remote domain.RemoteConfig, seen map[string]bool, key, value | |||
| 29 | 24 | on, ok := parseTrueFalse(value) | |
| 30 | 25 | if !ok { | |
| 31 | 26 | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid enabled %q", value)}) | |
| 32 | - | return remote, warnings | |
| 27 | + | return remote, warnings, nil | |
| 33 | 28 | } | |
| 34 | 29 | remote.Enabled = on | |
| 35 | 30 | case "remember": | |
| 36 | 31 | on, ok := parseTrueFalse(value) | |
| 37 | 32 | if !ok { | |
| 38 | 33 | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid remember %q", value)}) | |
| 39 | - | return remote, warnings | |
| 34 | + | return remote, warnings, nil | |
| 40 | 35 | } | |
| 41 | 36 | 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, warnings | |
| 38 | + | return remote, warnings, nil | |
| 51 | 39 | } | |
| 52 | 40 | ||
| 53 | 41 | func parseTrueFalse(value string) (bool, bool) { | |
| @@ -60,269 +48,3 @@ func parseTrueFalse(value string) (bool, bool) { | |||
| 60 | 48 | 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 | - | } | |
| @@ -1,27 +1,22 @@ | |||||
| 1 | package config | 1 | 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.Warning | 11 | 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, warnings | 19 | + | 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, warnings | 27 | + | return remote, warnings, nil |
| 33 | } | 28 | } | ||
| 34 | remote.Enabled = on | 29 | 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, warnings | 34 | + | return remote, warnings, nil |
| 40 | } | 35 | } | ||
| 41 | remote.Remember = on | 36 | 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, warnings | 38 | + | 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, false | 48 | 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 @@ | |||
| 1 | 1 | package config | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "os" | |
| 5 | - | "path/filepath" | |
| 6 | - | "reflect" | |
| 7 | - | "strconv" | |
| 8 | 4 | "strings" | |
| 9 | 5 | "testing" | |
| 10 | 6 | ||
| 11 | 7 | "github.com/bnema/vev/internal/domain" | |
| 12 | - | "github.com/bnema/vev/pkg/safedir" | |
| 13 | 8 | "github.com/stretchr/testify/require" | |
| 14 | 9 | ) | |
| 15 | 10 | ||
| @@ -20,6 +15,7 @@ func TestParseRemote(t *testing.T) { | |||
| 20 | 15 | name string | |
| 21 | 16 | input string | |
| 22 | 17 | want domain.RemoteConfig | |
| 18 | + | wantErr string | |
| 23 | 19 | wantWarnings []domain.Warning | |
| 24 | 20 | wantTheme domain.ThemeMode | |
| 25 | 21 | checkTheme bool | |
| @@ -28,19 +24,17 @@ func TestParseRemote(t *testing.T) { | |||
| 28 | 24 | }{ | |
| 29 | 25 | { | |
| 30 | 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 | 30 | name: "complete remote block", | |
| 35 | 31 | input: `[remote] | |
| 36 | 32 | enabled = true | |
| 37 | 33 | remember = true | |
| 38 | - | hosts = ["arch", "build@mule"] | |
| 39 | 34 | `, | |
| 40 | 35 | want: domain.RemoteConfig{ | |
| 41 | 36 | Enabled: true, | |
| 42 | 37 | Remember: true, | |
| 43 | - | Hosts: []string{"arch", "build@mule"}, | |
| 44 | 38 | }, | |
| 45 | 39 | }, | |
| 46 | 40 | { | |
| @@ -48,14 +42,14 @@ hosts = ["arch", "build@mule"] | |||
| 48 | 42 | input: `[remote] | |
| 49 | 43 | 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 | 48 | name: "remember false", | |
| 55 | 49 | input: `[remote] | |
| 56 | 50 | 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 | 55 | name: "legacy on off rejected and defaults retained", | |
| @@ -63,19 +57,12 @@ remember = false | |||
| 63 | 57 | enabled = off | |
| 64 | 58 | remember = on | |
| 65 | 59 | `, | |
| 66 | - | want: domain.RemoteConfig{Enabled: true, Remember: true, Hosts: []string{}}, | |
| 60 | + | want: domain.RemoteConfig{Enabled: true, Remember: true}, | |
| 67 | 61 | wantWarnings: []domain.Warning{ | |
| 68 | 62 | {Line: 2, Msg: `invalid enabled "off"`}, | |
| 69 | 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 | 67 | name: "duplicate keys warn and last valid wins", | |
| 81 | 68 | input: `[remote] | |
| @@ -83,14 +70,11 @@ enabled = false | |||
| 83 | 70 | enabled = true | |
| 84 | 71 | remember = false | |
| 85 | 72 | 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 | 75 | wantWarnings: []domain.Warning{ | |
| 91 | 76 | {Line: 3, Msg: `duplicate key "enabled"`}, | |
| 92 | 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 | 83 | enabled = yes | |
| 100 | 84 | remember = True | |
| 101 | 85 | `, | |
| 102 | - | want: domain.RemoteConfig{Enabled: true, Remember: true, Hosts: []string{}}, | |
| 86 | + | want: domain.RemoteConfig{Enabled: true, Remember: true}, | |
| 103 | 87 | wantWarnings: []domain.Warning{ | |
| 104 | 88 | {Line: 2, Msg: `invalid enabled "yes"`}, | |
| 105 | 89 | {Line: 3, Msg: `invalid remember "True"`}, | |
| 106 | 90 | }, | |
| 107 | 91 | }, | |
| 108 | 92 | { | |
| 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", | |
| 120 | 94 | input: `[remote] | |
| 121 | - | hosts = not-an-array | |
| 95 | + | hosts = ["arch", "build@mule"] | |
| 122 | 96 | `, | |
| 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`, | |
| 127 | 98 | }, | |
| 128 | 99 | { | |
| 129 | - | name: "null hosts retains default", | |
| 100 | + | name: "empty hosts array fails closed", | |
| 130 | 101 | input: `[remote] | |
| 131 | - | hosts = null | |
| 102 | + | hosts = [] | |
| 132 | 103 | `, | |
| 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`, | |
| 137 | 105 | }, | |
| 138 | 106 | { | |
| 139 | - | name: "invalid and duplicate host entries retain default", | |
| 107 | + | name: "malformed hosts assignment fails closed", | |
| 140 | 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{}}, | |
| 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`, | |
| 148 | 112 | }, | |
| 149 | 113 | { | |
| 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] | |
| 152 | 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"}}, | |
| 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`, | |
| 160 | 121 | }, | |
| 161 | 122 | { | |
| 162 | 123 | name: "unknown remote key warns", | |
| @@ -164,7 +125,7 @@ hosts = ["arch", " bad", "ok"] | |||
| 164 | 125 | proxy = true | |
| 165 | 126 | enabled = false | |
| 166 | 127 | `, | |
| 167 | - | want: domain.RemoteConfig{Enabled: false, Remember: true, Hosts: []string{}}, | |
| 128 | + | want: domain.RemoteConfig{Enabled: false, Remember: true}, | |
| 168 | 129 | wantWarnings: []domain.Warning{ | |
| 169 | 130 | {Line: 2, Msg: `unknown key "proxy"`}, | |
| 170 | 131 | }, | |
| @@ -177,7 +138,7 @@ enabled = false | |||
| 177 | 138 | [remote] | |
| 178 | 139 | enabled = false | |
| 179 | 140 | `, | |
| 180 | - | want: domain.RemoteConfig{Enabled: false, Remember: true, Hosts: []string{}}, | |
| 141 | + | want: domain.RemoteConfig{Enabled: false, Remember: true}, | |
| 181 | 142 | wantWarnings: []domain.Warning{ | |
| 182 | 143 | {Line: 2, Msg: `unknown section "other"`}, | |
| 183 | 144 | }, | |
| @@ -189,9 +150,9 @@ enabled = false | |||
| 189 | 150 | input: `theme = light | |
| 190 | 151 | new-tab = alt+t | |
| 191 | 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 | 156 | wantTheme: domain.ThemeLight, | |
| 196 | 157 | checkTheme: true, | |
| 197 | 158 | wantBindingEntries: []domain.ConfigEntry{{Key: "new-tab", Value: "alt+t"}}, | |
| @@ -204,6 +165,10 @@ hosts = ["arch"] | |||
| 204 | 165 | t.Parallel() | |
| 205 | 166 | ||
| 206 | 167 | cfg, warnings, err := Parse(strings.NewReader(tt.input)) | |
| 168 | + | if tt.wantErr != "" { | |
| 169 | + | require.EqualError(t, err, tt.wantErr) | |
| 170 | + | return | |
| 171 | + | } | |
| 207 | 172 | require.NoError(t, err) | |
| 208 | 173 | require.Equal(t, tt.want, cfg.Remote) | |
| 209 | 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 | - | } | |
| @@ -1,15 +1,10 @@ | |||||
| 1 | package config | 1 | 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 string | 15 | name string | ||
| 21 | input string | 16 | input string | ||
| 22 | want domain.RemoteConfig | 17 | want domain.RemoteConfig | ||
| 18 | + | wantErr string | |||
| 23 | wantWarnings []domain.Warning | 19 | wantWarnings []domain.Warning | ||
| 24 | wantTheme domain.ThemeMode | 20 | wantTheme domain.ThemeMode | ||
| 25 | checkTheme bool | 21 | 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 = true | 32 | enabled = true | ||
| 37 | remember = true | 33 | 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 = false | 43 | 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 = false | 50 | 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 = off | 57 | enabled = off | ||
| 64 | remember = on | 58 | 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 = true | 70 | enabled = true | ||
| 84 | remember = false | 71 | remember = false | ||
| 85 | remember = true | 72 | 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 = yes | 83 | enabled = yes | ||
| 100 | remember = True | 84 | 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-array | 95 | + | 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 = null | 102 | + | 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 = true | 125 | proxy = true | ||
| 165 | enabled = false | 126 | 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 = false | 139 | 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 = light | 150 | input: `theme = light | ||
| 190 | new-tab = alt+t | 151 | 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 { | |||
| 139 | 139 | Remote: RemoteConfig{ | |
| 140 | 140 | Enabled: true, | |
| 141 | 141 | Remember: true, | |
| 142 | - | Hosts: []string{}, | |
| 143 | 142 | }, | |
| 144 | 143 | } | |
| 145 | 144 | } | |
| @@ -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 ( | |||
| 6 | 6 | "unicode" | |
| 7 | 7 | ) | |
| 8 | 8 | ||
| 9 | - | // RemoteConfig contains user-configurable remote host settings. | |
| 9 | + | // RemoteConfig contains user-configurable remote scalar settings. | |
| 10 | 10 | type RemoteConfig struct { | |
| 11 | 11 | Enabled bool | |
| 12 | 12 | Remember bool | |
| 13 | - | Hosts []string | |
| 14 | 13 | } | |
| 15 | 14 | ||
| 16 | 15 | // 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 bool | 11 | Enabled bool | ||
| 12 | Remember bool | 12 | 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) { | |||
| 80 | 80 | if !cfg.Remote.Remember { | |
| 81 | 81 | t.Fatal("Defaults().Remote.Remember = false, want true") | |
| 82 | 82 | } | |
| 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) | |
| 88 | 85 | } | |
| 89 | 86 | } | |
| @@ -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 | } | ||