Merge pull request #151 from bnema/feat/remote-config
feat(remote): add remote configuration
7 files · +956 -1
M internal/adapters/config/config.go +26 -1
| @@ -21,11 +21,13 @@ const pollInterval = 2 * time.Second | |||
| 21 | 21 | var ( | |
| 22 | 22 | processNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$`) | |
| 23 | 23 | percentagePattern = regexp.MustCompile(`^[0-9]{1,3}%$`) | |
| 24 | + | sectionPattern = regexp.MustCompile(`^\[([^\]]+)\]$`) | |
| 24 | 25 | ) | |
| 25 | 26 | ||
| 26 | 27 | // Parse reads vev's flat action = value config format. Duplicate action keys | |
| 27 | 28 | // are accepted with a warning; the last value wins while first-seen action order | |
| 28 | - | // is preserved for binding conflict resolution. | |
| 29 | + | // is preserved for binding conflict resolution. An optional [remote] section | |
| 30 | + | // accepts enabled/remember/hosts with TOML-style true/false values. | |
| 29 | 31 | func Parse(r io.Reader) (domain.Config, []domain.Warning, error) { | |
| 30 | 32 | cfg := domain.Defaults() | |
| 31 | 33 | var warnings []domain.Warning | |
| @@ -35,6 +37,8 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) { | |||
| 35 | 37 | seenPaletteKeys := make(map[string]bool) | |
| 36 | 38 | seenNavKeys := make(map[string]bool) | |
| 37 | 39 | seenTabsKeys := make(map[string]bool) | |
| 40 | + | seenRemoteKeys := make(map[string]bool) | |
| 41 | + | section := "" | |
| 38 | 42 | ||
| 39 | 43 | scanner := bufio.NewScanner(r) | |
| 40 | 44 | lineNo := 0 | |
| @@ -49,6 +53,17 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) { | |||
| 49 | 53 | continue | |
| 50 | 54 | } | |
| 51 | 55 | ||
| 56 | + | if matches := sectionPattern.FindStringSubmatch(line); matches != nil { | |
| 57 | + | name := matches[1] | |
| 58 | + | if name == "remote" { | |
| 59 | + | section = "remote" | |
| 60 | + | } else { | |
| 61 | + | section = "unknown:" + name | |
| 62 | + | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("unknown section %q", name)}) | |
| 63 | + | } | |
| 64 | + | continue | |
| 65 | + | } | |
| 66 | + | ||
| 52 | 67 | key, value, ok := strings.Cut(line, "=") | |
| 53 | 68 | if !ok { | |
| 54 | 69 | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: "missing '='"}) | |
| @@ -61,6 +76,16 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) { | |||
| 61 | 76 | continue | |
| 62 | 77 | } | |
| 63 | 78 | ||
| 79 | + | if strings.HasPrefix(section, "unknown:") { | |
| 80 | + | continue | |
| 81 | + | } | |
| 82 | + | if section == "remote" { | |
| 83 | + | var remoteWarnings []domain.Warning | |
| 84 | + | cfg.Remote, remoteWarnings = parseRemoteKey(cfg.Remote, seenRemoteKeys, key, value, lineNo) | |
| 85 | + | warnings = append(warnings, remoteWarnings...) | |
| 86 | + | continue | |
| 87 | + | } | |
| 88 | + | ||
| 64 | 89 | switch { | |
| 65 | 90 | case key == "theme": | |
| 66 | 91 | mode, ok := parseTheme(value) | |
| @@ -21,11 +21,13 @@ const pollInterval = 2 * time.Second | |||||
| 21 | var ( | 21 | var ( | ||
| 22 | processNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$`) | 22 | processNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$`) | ||
| 23 | percentagePattern = regexp.MustCompile(`^[0-9]{1,3}%$`) | 23 | percentagePattern = regexp.MustCompile(`^[0-9]{1,3}%$`) | ||
| 24 | + | sectionPattern = regexp.MustCompile(`^\[([^\]]+)\]$`) | |||
| 24 | ) | 25 | ) | ||
| 25 | 26 | ||||
| 26 | // 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 | ||
| 27 | // 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 | ||
| 28 | - | // is preserved for binding conflict resolution. | 29 | + | // is preserved for binding conflict resolution. An optional [remote] section |
| 30 | + | // accepts enabled/remember/hosts with TOML-style true/false values. | |||
| 29 | func Parse(r io.Reader) (domain.Config, []domain.Warning, error) { | 31 | func Parse(r io.Reader) (domain.Config, []domain.Warning, error) { | ||
| 30 | cfg := domain.Defaults() | 32 | cfg := domain.Defaults() | ||
| 31 | var warnings []domain.Warning | 33 | var warnings []domain.Warning | ||
| @@ -35,6 +37,8 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) { | |||||
| 35 | seenPaletteKeys := make(map[string]bool) | 37 | seenPaletteKeys := make(map[string]bool) | ||
| 36 | seenNavKeys := make(map[string]bool) | 38 | seenNavKeys := make(map[string]bool) | ||
| 37 | seenTabsKeys := make(map[string]bool) | 39 | seenTabsKeys := make(map[string]bool) | ||
| 40 | + | seenRemoteKeys := make(map[string]bool) | |||
| 41 | + | section := "" | |||
| 38 | 42 | ||||
| 39 | scanner := bufio.NewScanner(r) | 43 | scanner := bufio.NewScanner(r) | ||
| 40 | lineNo := 0 | 44 | lineNo := 0 | ||
| @@ -49,6 +53,17 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) { | |||||
| 49 | continue | 53 | continue | ||
| 50 | } | 54 | } | ||
| 51 | 55 | ||||
| 56 | + | if matches := sectionPattern.FindStringSubmatch(line); matches != nil { | |||
| 57 | + | name := matches[1] | |||
| 58 | + | if name == "remote" { | |||
| 59 | + | section = "remote" | |||
| 60 | + | } else { | |||
| 61 | + | section = "unknown:" + name | |||
| 62 | + | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("unknown section %q", name)}) | |||
| 63 | + | } | |||
| 64 | + | continue | |||
| 65 | + | } | |||
| 66 | + | ||||
| 52 | key, value, ok := strings.Cut(line, "=") | 67 | key, value, ok := strings.Cut(line, "=") | ||
| 53 | if !ok { | 68 | if !ok { | ||
| 54 | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: "missing '='"}) | 69 | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: "missing '='"}) | ||
| @@ -61,6 +76,16 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) { | |||||
| 61 | continue | 76 | continue | ||
| 62 | } | 77 | } | ||
| 63 | 78 | ||||
| 79 | + | if strings.HasPrefix(section, "unknown:") { | |||
| 80 | + | continue | |||
| 81 | + | } | |||
| 82 | + | if section == "remote" { | |||
| 83 | + | var remoteWarnings []domain.Warning | |||
| 84 | + | cfg.Remote, remoteWarnings = parseRemoteKey(cfg.Remote, seenRemoteKeys, key, value, lineNo) | |||
| 85 | + | warnings = append(warnings, remoteWarnings...) | |||
| 86 | + | continue | |||
| 87 | + | } | |||
| 88 | + | ||||
| 64 | switch { | 89 | switch { | ||
| 65 | case key == "theme": | 90 | case key == "theme": | ||
| 66 | mode, ok := parseTheme(value) | 91 | mode, ok := parseTheme(value) | ||
M internal/adapters/config/config_test.go +3 -0
| @@ -358,6 +358,9 @@ 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 { | |
| 362 | + | tt.want.Remote = domain.Defaults().Remote | |
| 363 | + | } | |
| 361 | 364 | got, warnings, err := Parse(strings.NewReader(tt.input)) | |
| 362 | 365 | if err != nil { | |
| 363 | 366 | t.Fatalf("Parse() error = %v", err) | |
| @@ -358,6 +358,9 @@ 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 { | |||
| 362 | + | tt.want.Remote = domain.Defaults().Remote | |||
| 363 | + | } | |||
| 361 | got, warnings, err := Parse(strings.NewReader(tt.input)) | 364 | got, warnings, err := Parse(strings.NewReader(tt.input)) | ||
| 362 | if err != nil { | 365 | if err != nil { | ||
| 363 | t.Fatalf("Parse() error = %v", err) | 366 | t.Fatalf("Parse() error = %v", err) | ||
A internal/adapters/config/remote.go +328 -0
| @@ -0,0 +1,328 @@ | |||
| 1 | + | package config | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "encoding/json" | |
| 6 | + | "errors" | |
| 7 | + | "fmt" | |
| 8 | + | "os" | |
| 9 | + | "path/filepath" | |
| 10 | + | "strings" | |
| 11 | + | "syscall" | |
| 12 | + | ||
| 13 | + | "github.com/bnema/vev/internal/domain" | |
| 14 | + | "github.com/bnema/vev/pkg/safedir" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | func parseRemoteKey(remote domain.RemoteConfig, seen map[string]bool, key, value string, lineNo int) (domain.RemoteConfig, []domain.Warning) { | |
| 18 | + | var warnings []domain.Warning | |
| 19 | + | switch key { | |
| 20 | + | case "enabled", "remember", "hosts": | |
| 21 | + | warnings = warnDuplicateKey(warnings, seen, key, lineNo) | |
| 22 | + | default: | |
| 23 | + | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("unknown key %q", key)}) | |
| 24 | + | return remote, warnings | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | switch key { | |
| 28 | + | case "enabled": | |
| 29 | + | on, ok := parseTrueFalse(value) | |
| 30 | + | if !ok { | |
| 31 | + | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid enabled %q", value)}) | |
| 32 | + | return remote, warnings | |
| 33 | + | } | |
| 34 | + | remote.Enabled = on | |
| 35 | + | case "remember": | |
| 36 | + | on, ok := parseTrueFalse(value) | |
| 37 | + | if !ok { | |
| 38 | + | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid remember %q", value)}) | |
| 39 | + | return remote, warnings | |
| 40 | + | } | |
| 41 | + | 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 | + | } | |
| 50 | + | return remote, warnings | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func parseTrueFalse(value string) (bool, bool) { | |
| 54 | + | switch strings.TrimSpace(value) { | |
| 55 | + | case "true": | |
| 56 | + | return true, true | |
| 57 | + | case "false": | |
| 58 | + | return false, true | |
| 59 | + | default: | |
| 60 | + | return false, false | |
| 61 | + | } | |
| 62 | + | } | |
| 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 | + | } | |
| @@ -0,0 +1,328 @@ | |||||
| 1 | + | package config | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "errors" | |||
| 7 | + | "fmt" | |||
| 8 | + | "os" | |||
| 9 | + | "path/filepath" | |||
| 10 | + | "strings" | |||
| 11 | + | "syscall" | |||
| 12 | + | ||||
| 13 | + | "github.com/bnema/vev/internal/domain" | |||
| 14 | + | "github.com/bnema/vev/pkg/safedir" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | func parseRemoteKey(remote domain.RemoteConfig, seen map[string]bool, key, value string, lineNo int) (domain.RemoteConfig, []domain.Warning) { | |||
| 18 | + | var warnings []domain.Warning | |||
| 19 | + | switch key { | |||
| 20 | + | case "enabled", "remember", "hosts": | |||
| 21 | + | warnings = warnDuplicateKey(warnings, seen, key, lineNo) | |||
| 22 | + | default: | |||
| 23 | + | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("unknown key %q", key)}) | |||
| 24 | + | return remote, warnings | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | switch key { | |||
| 28 | + | case "enabled": | |||
| 29 | + | on, ok := parseTrueFalse(value) | |||
| 30 | + | if !ok { | |||
| 31 | + | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid enabled %q", value)}) | |||
| 32 | + | return remote, warnings | |||
| 33 | + | } | |||
| 34 | + | remote.Enabled = on | |||
| 35 | + | case "remember": | |||
| 36 | + | on, ok := parseTrueFalse(value) | |||
| 37 | + | if !ok { | |||
| 38 | + | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid remember %q", value)}) | |||
| 39 | + | return remote, warnings | |||
| 40 | + | } | |||
| 41 | + | 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 | + | } | |||
| 50 | + | return remote, warnings | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | func parseTrueFalse(value string) (bool, bool) { | |||
| 54 | + | switch strings.TrimSpace(value) { | |||
| 55 | + | case "true": | |||
| 56 | + | return true, true | |||
| 57 | + | case "false": | |||
| 58 | + | return false, true | |||
| 59 | + | default: | |||
| 60 | + | return false, false | |||
| 61 | + | } | |||
| 62 | + | } | |||
| 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 | + | } | |||
A internal/adapters/config/remote_test.go +452 -0
| @@ -0,0 +1,452 @@ | |||
| 1 | + | package config | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "os" | |
| 5 | + | "path/filepath" | |
| 6 | + | "reflect" | |
| 7 | + | "strconv" | |
| 8 | + | "strings" | |
| 9 | + | "testing" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/vev/internal/domain" | |
| 12 | + | "github.com/bnema/vev/pkg/safedir" | |
| 13 | + | "github.com/stretchr/testify/require" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | func TestParseRemote(t *testing.T) { | |
| 17 | + | t.Parallel() | |
| 18 | + | ||
| 19 | + | tests := []struct { | |
| 20 | + | name string | |
| 21 | + | input string | |
| 22 | + | want domain.RemoteConfig | |
| 23 | + | wantWarnings []domain.Warning | |
| 24 | + | wantTheme domain.ThemeMode | |
| 25 | + | checkTheme bool | |
| 26 | + | wantBindingEntries []domain.ConfigEntry | |
| 27 | + | checkBindings bool | |
| 28 | + | }{ | |
| 29 | + | { | |
| 30 | + | name: "defaults when section absent", | |
| 31 | + | want: domain.RemoteConfig{Enabled: true, Remember: true, Hosts: []string{}}, | |
| 32 | + | }, | |
| 33 | + | { | |
| 34 | + | name: "complete remote block", | |
| 35 | + | input: `[remote] | |
| 36 | + | enabled = true | |
| 37 | + | remember = true | |
| 38 | + | hosts = ["arch", "build@mule"] | |
| 39 | + | `, | |
| 40 | + | want: domain.RemoteConfig{ | |
| 41 | + | Enabled: true, | |
| 42 | + | Remember: true, | |
| 43 | + | Hosts: []string{"arch", "build@mule"}, | |
| 44 | + | }, | |
| 45 | + | }, | |
| 46 | + | { | |
| 47 | + | name: "enabled false", | |
| 48 | + | input: `[remote] | |
| 49 | + | enabled = false | |
| 50 | + | `, | |
| 51 | + | want: domain.RemoteConfig{Enabled: false, Remember: true, Hosts: []string{}}, | |
| 52 | + | }, | |
| 53 | + | { | |
| 54 | + | name: "remember false", | |
| 55 | + | input: `[remote] | |
| 56 | + | remember = false | |
| 57 | + | `, | |
| 58 | + | want: domain.RemoteConfig{Enabled: true, Remember: false, Hosts: []string{}}, | |
| 59 | + | }, | |
| 60 | + | { | |
| 61 | + | name: "legacy on off rejected and defaults retained", | |
| 62 | + | input: `[remote] | |
| 63 | + | enabled = off | |
| 64 | + | remember = on | |
| 65 | + | `, | |
| 66 | + | want: domain.RemoteConfig{Enabled: true, Remember: true, Hosts: []string{}}, | |
| 67 | + | wantWarnings: []domain.Warning{ | |
| 68 | + | {Line: 2, Msg: `invalid enabled "off"`}, | |
| 69 | + | {Line: 3, Msg: `invalid remember "on"`}, | |
| 70 | + | }, | |
| 71 | + | }, | |
| 72 | + | { | |
| 73 | + | name: "empty hosts array", | |
| 74 | + | input: `[remote] | |
| 75 | + | hosts = [] | |
| 76 | + | `, | |
| 77 | + | want: domain.RemoteConfig{Enabled: true, Remember: true, Hosts: []string{}}, | |
| 78 | + | }, | |
| 79 | + | { | |
| 80 | + | name: "duplicate keys warn and last valid wins", | |
| 81 | + | input: `[remote] | |
| 82 | + | enabled = false | |
| 83 | + | enabled = true | |
| 84 | + | remember = false | |
| 85 | + | remember = true | |
| 86 | + | hosts = ["arch"] | |
| 87 | + | hosts = ["mule"] | |
| 88 | + | `, | |
| 89 | + | want: domain.RemoteConfig{Enabled: true, Remember: true, Hosts: []string{"mule"}}, | |
| 90 | + | wantWarnings: []domain.Warning{ | |
| 91 | + | {Line: 3, Msg: `duplicate key "enabled"`}, | |
| 92 | + | {Line: 5, Msg: `duplicate key "remember"`}, | |
| 93 | + | {Line: 7, Msg: `duplicate key "hosts"`}, | |
| 94 | + | }, | |
| 95 | + | }, | |
| 96 | + | { | |
| 97 | + | name: "invalid booleans warn and retain defaults", | |
| 98 | + | input: `[remote] | |
| 99 | + | enabled = yes | |
| 100 | + | remember = True | |
| 101 | + | `, | |
| 102 | + | want: domain.RemoteConfig{Enabled: true, Remember: true, Hosts: []string{}}, | |
| 103 | + | wantWarnings: []domain.Warning{ | |
| 104 | + | {Line: 2, Msg: `invalid enabled "yes"`}, | |
| 105 | + | {Line: 3, Msg: `invalid remember "True"`}, | |
| 106 | + | }, | |
| 107 | + | }, | |
| 108 | + | { | |
| 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", | |
| 120 | + | input: `[remote] | |
| 121 | + | hosts = not-an-array | |
| 122 | + | `, | |
| 123 | + | want: domain.RemoteConfig{Enabled: true, Remember: true, Hosts: []string{}}, | |
| 124 | + | wantWarnings: []domain.Warning{ | |
| 125 | + | {Line: 2, Msg: `invalid hosts "not-an-array"`}, | |
| 126 | + | }, | |
| 127 | + | }, | |
| 128 | + | { | |
| 129 | + | name: "null hosts retains default", | |
| 130 | + | input: `[remote] | |
| 131 | + | hosts = null | |
| 132 | + | `, | |
| 133 | + | want: domain.RemoteConfig{Enabled: true, Remember: true, Hosts: []string{}}, | |
| 134 | + | wantWarnings: []domain.Warning{ | |
| 135 | + | {Line: 2, Msg: `invalid hosts "null"`}, | |
| 136 | + | }, | |
| 137 | + | }, | |
| 138 | + | { | |
| 139 | + | name: "invalid and duplicate host entries retain default", | |
| 140 | + | input: `[remote] | |
| 141 | + | hosts = ["arch", "arch", " bad", "ok"] | |
| 142 | + | `, | |
| 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 | + | }, | |
| 148 | + | }, | |
| 149 | + | { | |
| 150 | + | name: "invalid hosts assignment retains previous value", | |
| 151 | + | input: `[remote] | |
| 152 | + | hosts = ["arch"] | |
| 153 | + | hosts = ["arch", " bad", "ok"] | |
| 154 | + | `, | |
| 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 | + | }, | |
| 160 | + | }, | |
| 161 | + | { | |
| 162 | + | name: "unknown remote key warns", | |
| 163 | + | input: `[remote] | |
| 164 | + | proxy = true | |
| 165 | + | enabled = false | |
| 166 | + | `, | |
| 167 | + | want: domain.RemoteConfig{Enabled: false, Remember: true, Hosts: []string{}}, | |
| 168 | + | wantWarnings: []domain.Warning{ | |
| 169 | + | {Line: 2, Msg: `unknown key "proxy"`}, | |
| 170 | + | }, | |
| 171 | + | }, | |
| 172 | + | { | |
| 173 | + | name: "unknown section warns and ignores its keys", | |
| 174 | + | input: `theme = dark | |
| 175 | + | [other] | |
| 176 | + | enabled = false | |
| 177 | + | [remote] | |
| 178 | + | enabled = false | |
| 179 | + | `, | |
| 180 | + | want: domain.RemoteConfig{Enabled: false, Remember: true, Hosts: []string{}}, | |
| 181 | + | wantWarnings: []domain.Warning{ | |
| 182 | + | {Line: 2, Msg: `unknown section "other"`}, | |
| 183 | + | }, | |
| 184 | + | wantTheme: domain.ThemeDark, | |
| 185 | + | checkTheme: true, | |
| 186 | + | }, | |
| 187 | + | { | |
| 188 | + | name: "flat keys preserved outside remote section", | |
| 189 | + | input: `theme = light | |
| 190 | + | new-tab = alt+t | |
| 191 | + | [remote] | |
| 192 | + | hosts = ["arch"] | |
| 193 | + | `, | |
| 194 | + | want: domain.RemoteConfig{Enabled: true, Remember: true, Hosts: []string{"arch"}}, | |
| 195 | + | wantTheme: domain.ThemeLight, | |
| 196 | + | checkTheme: true, | |
| 197 | + | wantBindingEntries: []domain.ConfigEntry{{Key: "new-tab", Value: "alt+t"}}, | |
| 198 | + | checkBindings: true, | |
| 199 | + | }, | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | for _, tt := range tests { | |
| 203 | + | t.Run(tt.name, func(t *testing.T) { | |
| 204 | + | t.Parallel() | |
| 205 | + | ||
| 206 | + | cfg, warnings, err := Parse(strings.NewReader(tt.input)) | |
| 207 | + | require.NoError(t, err) | |
| 208 | + | require.Equal(t, tt.want, cfg.Remote) | |
| 209 | + | require.Equal(t, tt.wantWarnings, warnings) | |
| 210 | + | if tt.checkTheme { | |
| 211 | + | require.Equal(t, tt.wantTheme, cfg.Theme) | |
| 212 | + | } | |
| 213 | + | if tt.checkBindings { | |
| 214 | + | require.Equal(t, tt.wantBindingEntries, cfg.BindingEntries) | |
| 215 | + | } | |
| 216 | + | }) | |
| 217 | + | } | |
| 218 | + | } | |
| 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 | + | } | |
| @@ -0,0 +1,452 @@ | |||||
| 1 | + | package config | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "os" | |||
| 5 | + | "path/filepath" | |||
| 6 | + | "reflect" | |||
| 7 | + | "strconv" | |||
| 8 | + | "strings" | |||
| 9 | + | "testing" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/vev/internal/domain" | |||
| 12 | + | "github.com/bnema/vev/pkg/safedir" | |||
| 13 | + | "github.com/stretchr/testify/require" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | func TestParseRemote(t *testing.T) { | |||
| 17 | + | t.Parallel() | |||
| 18 | + | ||||
| 19 | + | tests := []struct { | |||
| 20 | + | name string | |||
| 21 | + | input string | |||
| 22 | + | want domain.RemoteConfig | |||
| 23 | + | wantWarnings []domain.Warning | |||
| 24 | + | wantTheme domain.ThemeMode | |||
| 25 | + | checkTheme bool | |||
| 26 | + | wantBindingEntries []domain.ConfigEntry | |||
| 27 | + | checkBindings bool | |||
| 28 | + | }{ | |||
| 29 | + | { | |||
| 30 | + | name: "defaults when section absent", | |||
| 31 | + | want: domain.RemoteConfig{Enabled: true, Remember: true, Hosts: []string{}}, | |||
| 32 | + | }, | |||
| 33 | + | { | |||
| 34 | + | name: "complete remote block", | |||
| 35 | + | input: `[remote] | |||
| 36 | + | enabled = true | |||
| 37 | + | remember = true | |||
| 38 | + | hosts = ["arch", "build@mule"] | |||
| 39 | + | `, | |||
| 40 | + | want: domain.RemoteConfig{ | |||
| 41 | + | Enabled: true, | |||
| 42 | + | Remember: true, | |||
| 43 | + | Hosts: []string{"arch", "build@mule"}, | |||
| 44 | + | }, | |||
| 45 | + | }, | |||
| 46 | + | { | |||
| 47 | + | name: "enabled false", | |||
| 48 | + | input: `[remote] | |||
| 49 | + | enabled = false | |||
| 50 | + | `, | |||
| 51 | + | want: domain.RemoteConfig{Enabled: false, Remember: true, Hosts: []string{}}, | |||
| 52 | + | }, | |||
| 53 | + | { | |||
| 54 | + | name: "remember false", | |||
| 55 | + | input: `[remote] | |||
| 56 | + | remember = false | |||
| 57 | + | `, | |||
| 58 | + | want: domain.RemoteConfig{Enabled: true, Remember: false, Hosts: []string{}}, | |||
| 59 | + | }, | |||
| 60 | + | { | |||
| 61 | + | name: "legacy on off rejected and defaults retained", | |||
| 62 | + | input: `[remote] | |||
| 63 | + | enabled = off | |||
| 64 | + | remember = on | |||
| 65 | + | `, | |||
| 66 | + | want: domain.RemoteConfig{Enabled: true, Remember: true, Hosts: []string{}}, | |||
| 67 | + | wantWarnings: []domain.Warning{ | |||
| 68 | + | {Line: 2, Msg: `invalid enabled "off"`}, | |||
| 69 | + | {Line: 3, Msg: `invalid remember "on"`}, | |||
| 70 | + | }, | |||
| 71 | + | }, | |||
| 72 | + | { | |||
| 73 | + | name: "empty hosts array", | |||
| 74 | + | input: `[remote] | |||
| 75 | + | hosts = [] | |||
| 76 | + | `, | |||
| 77 | + | want: domain.RemoteConfig{Enabled: true, Remember: true, Hosts: []string{}}, | |||
| 78 | + | }, | |||
| 79 | + | { | |||
| 80 | + | name: "duplicate keys warn and last valid wins", | |||
| 81 | + | input: `[remote] | |||
| 82 | + | enabled = false | |||
| 83 | + | enabled = true | |||
| 84 | + | remember = false | |||
| 85 | + | remember = true | |||
| 86 | + | hosts = ["arch"] | |||
| 87 | + | hosts = ["mule"] | |||
| 88 | + | `, | |||
| 89 | + | want: domain.RemoteConfig{Enabled: true, Remember: true, Hosts: []string{"mule"}}, | |||
| 90 | + | wantWarnings: []domain.Warning{ | |||
| 91 | + | {Line: 3, Msg: `duplicate key "enabled"`}, | |||
| 92 | + | {Line: 5, Msg: `duplicate key "remember"`}, | |||
| 93 | + | {Line: 7, Msg: `duplicate key "hosts"`}, | |||
| 94 | + | }, | |||
| 95 | + | }, | |||
| 96 | + | { | |||
| 97 | + | name: "invalid booleans warn and retain defaults", | |||
| 98 | + | input: `[remote] | |||
| 99 | + | enabled = yes | |||
| 100 | + | remember = True | |||
| 101 | + | `, | |||
| 102 | + | want: domain.RemoteConfig{Enabled: true, Remember: true, Hosts: []string{}}, | |||
| 103 | + | wantWarnings: []domain.Warning{ | |||
| 104 | + | {Line: 2, Msg: `invalid enabled "yes"`}, | |||
| 105 | + | {Line: 3, Msg: `invalid remember "True"`}, | |||
| 106 | + | }, | |||
| 107 | + | }, | |||
| 108 | + | { | |||
| 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", | |||
| 120 | + | input: `[remote] | |||
| 121 | + | hosts = not-an-array | |||
| 122 | + | `, | |||
| 123 | + | want: domain.RemoteConfig{Enabled: true, Remember: true, Hosts: []string{}}, | |||
| 124 | + | wantWarnings: []domain.Warning{ | |||
| 125 | + | {Line: 2, Msg: `invalid hosts "not-an-array"`}, | |||
| 126 | + | }, | |||
| 127 | + | }, | |||
| 128 | + | { | |||
| 129 | + | name: "null hosts retains default", | |||
| 130 | + | input: `[remote] | |||
| 131 | + | hosts = null | |||
| 132 | + | `, | |||
| 133 | + | want: domain.RemoteConfig{Enabled: true, Remember: true, Hosts: []string{}}, | |||
| 134 | + | wantWarnings: []domain.Warning{ | |||
| 135 | + | {Line: 2, Msg: `invalid hosts "null"`}, | |||
| 136 | + | }, | |||
| 137 | + | }, | |||
| 138 | + | { | |||
| 139 | + | name: "invalid and duplicate host entries retain default", | |||
| 140 | + | input: `[remote] | |||
| 141 | + | hosts = ["arch", "arch", " bad", "ok"] | |||
| 142 | + | `, | |||
| 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 | + | }, | |||
| 148 | + | }, | |||
| 149 | + | { | |||
| 150 | + | name: "invalid hosts assignment retains previous value", | |||
| 151 | + | input: `[remote] | |||
| 152 | + | hosts = ["arch"] | |||
| 153 | + | hosts = ["arch", " bad", "ok"] | |||
| 154 | + | `, | |||
| 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 | + | }, | |||
| 160 | + | }, | |||
| 161 | + | { | |||
| 162 | + | name: "unknown remote key warns", | |||
| 163 | + | input: `[remote] | |||
| 164 | + | proxy = true | |||
| 165 | + | enabled = false | |||
| 166 | + | `, | |||
| 167 | + | want: domain.RemoteConfig{Enabled: false, Remember: true, Hosts: []string{}}, | |||
| 168 | + | wantWarnings: []domain.Warning{ | |||
| 169 | + | {Line: 2, Msg: `unknown key "proxy"`}, | |||
| 170 | + | }, | |||
| 171 | + | }, | |||
| 172 | + | { | |||
| 173 | + | name: "unknown section warns and ignores its keys", | |||
| 174 | + | input: `theme = dark | |||
| 175 | + | [other] | |||
| 176 | + | enabled = false | |||
| 177 | + | [remote] | |||
| 178 | + | enabled = false | |||
| 179 | + | `, | |||
| 180 | + | want: domain.RemoteConfig{Enabled: false, Remember: true, Hosts: []string{}}, | |||
| 181 | + | wantWarnings: []domain.Warning{ | |||
| 182 | + | {Line: 2, Msg: `unknown section "other"`}, | |||
| 183 | + | }, | |||
| 184 | + | wantTheme: domain.ThemeDark, | |||
| 185 | + | checkTheme: true, | |||
| 186 | + | }, | |||
| 187 | + | { | |||
| 188 | + | name: "flat keys preserved outside remote section", | |||
| 189 | + | input: `theme = light | |||
| 190 | + | new-tab = alt+t | |||
| 191 | + | [remote] | |||
| 192 | + | hosts = ["arch"] | |||
| 193 | + | `, | |||
| 194 | + | want: domain.RemoteConfig{Enabled: true, Remember: true, Hosts: []string{"arch"}}, | |||
| 195 | + | wantTheme: domain.ThemeLight, | |||
| 196 | + | checkTheme: true, | |||
| 197 | + | wantBindingEntries: []domain.ConfigEntry{{Key: "new-tab", Value: "alt+t"}}, | |||
| 198 | + | checkBindings: true, | |||
| 199 | + | }, | |||
| 200 | + | } | |||
| 201 | + | ||||
| 202 | + | for _, tt := range tests { | |||
| 203 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 204 | + | t.Parallel() | |||
| 205 | + | ||||
| 206 | + | cfg, warnings, err := Parse(strings.NewReader(tt.input)) | |||
| 207 | + | require.NoError(t, err) | |||
| 208 | + | require.Equal(t, tt.want, cfg.Remote) | |||
| 209 | + | require.Equal(t, tt.wantWarnings, warnings) | |||
| 210 | + | if tt.checkTheme { | |||
| 211 | + | require.Equal(t, tt.wantTheme, cfg.Theme) | |||
| 212 | + | } | |||
| 213 | + | if tt.checkBindings { | |||
| 214 | + | require.Equal(t, tt.wantBindingEntries, cfg.BindingEntries) | |||
| 215 | + | } | |||
| 216 | + | }) | |||
| 217 | + | } | |||
| 218 | + | } | |||
| 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 +6 -0
| @@ -98,6 +98,7 @@ type Config struct { | |||
| 98 | 98 | Floating FloatingConfig | |
| 99 | 99 | Nav NavConfig | |
| 100 | 100 | Tabs TabsConfig | |
| 101 | + | Remote RemoteConfig | |
| 101 | 102 | } | |
| 102 | 103 | ||
| 103 | 104 | // Warning describes a non-fatal config problem. Parsers and reloaders should | |
| @@ -135,5 +136,10 @@ func Defaults() Config { | |||
| 135 | 136 | Tabs: TabsConfig{ | |
| 136 | 137 | TerminalTitle: true, | |
| 137 | 138 | }, | |
| 139 | + | Remote: RemoteConfig{ | |
| 140 | + | Enabled: true, | |
| 141 | + | Remember: true, | |
| 142 | + | Hosts: []string{}, | |
| 143 | + | }, | |
| 138 | 144 | } | |
| 139 | 145 | } | |
| @@ -98,6 +98,7 @@ type Config struct { | |||||
| 98 | Floating FloatingConfig | 98 | Floating FloatingConfig | ||
| 99 | Nav NavConfig | 99 | Nav NavConfig | ||
| 100 | Tabs TabsConfig | 100 | Tabs TabsConfig | ||
| 101 | + | Remote RemoteConfig | |||
| 101 | } | 102 | } | ||
| 102 | 103 | ||||
| 103 | // Warning describes a non-fatal config problem. Parsers and reloaders should | 104 | // Warning describes a non-fatal config problem. Parsers and reloaders should | ||
| @@ -135,5 +136,10 @@ func Defaults() Config { | |||||
| 135 | Tabs: TabsConfig{ | 136 | Tabs: TabsConfig{ | ||
| 136 | TerminalTitle: true, | 137 | TerminalTitle: true, | ||
| 137 | }, | 138 | }, | ||
| 139 | + | Remote: RemoteConfig{ | |||
| 140 | + | Enabled: true, | |||
| 141 | + | Remember: true, | |||
| 142 | + | Hosts: []string{}, | |||
| 143 | + | }, | |||
| 138 | } | 144 | } | ||
| 139 | } | 145 | } | ||
A internal/domain/remote_host.go +52 -0
| @@ -0,0 +1,52 @@ | |||
| 1 | + | package domain | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "strings" | |
| 6 | + | "unicode" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // RemoteConfig contains user-configurable remote host settings. | |
| 10 | + | type RemoteConfig struct { | |
| 11 | + | Enabled bool | |
| 12 | + | Remember bool | |
| 13 | + | Hosts []string | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | // RemoteHost is a merged remote host entry with its source markers. | |
| 17 | + | type RemoteHost struct { | |
| 18 | + | Target string | |
| 19 | + | Pinned bool | |
| 20 | + | Learned bool | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | // ValidateRemoteHostTarget rejects empty targets, surrounding/internal whitespace, | |
| 24 | + | // and control characters. SSH target grammar is left to OpenSSH. | |
| 25 | + | func ValidateRemoteHostTarget(target string) error { | |
| 26 | + | if target == "" { | |
| 27 | + | return fmt.Errorf("remote host target is empty") | |
| 28 | + | } | |
| 29 | + | if strings.TrimSpace(target) != target { | |
| 30 | + | return fmt.Errorf("remote host target %q has surrounding whitespace", target) | |
| 31 | + | } | |
| 32 | + | for _, r := range target { | |
| 33 | + | if unicode.IsSpace(r) || unicode.IsControl(r) { | |
| 34 | + | return fmt.Errorf("remote host target %q contains whitespace or control characters", target) | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | return nil | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | // UniqueRemoteHostTargets returns a de-duplicated copy preserving first occurrence order. | |
| 41 | + | func UniqueRemoteHostTargets(values []string) []string { | |
| 42 | + | seen := make(map[string]struct{}, len(values)) | |
| 43 | + | out := make([]string, 0, len(values)) | |
| 44 | + | for _, value := range values { | |
| 45 | + | if _, ok := seen[value]; ok { | |
| 46 | + | continue | |
| 47 | + | } | |
| 48 | + | seen[value] = struct{}{} | |
| 49 | + | out = append(out, value) | |
| 50 | + | } | |
| 51 | + | return out | |
| 52 | + | } | |
| @@ -0,0 +1,52 @@ | |||||
| 1 | + | package domain | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "strings" | |||
| 6 | + | "unicode" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // RemoteConfig contains user-configurable remote host settings. | |||
| 10 | + | type RemoteConfig struct { | |||
| 11 | + | Enabled bool | |||
| 12 | + | Remember bool | |||
| 13 | + | Hosts []string | |||
| 14 | + | } | |||
| 15 | + | ||||
| 16 | + | // RemoteHost is a merged remote host entry with its source markers. | |||
| 17 | + | type RemoteHost struct { | |||
| 18 | + | Target string | |||
| 19 | + | Pinned bool | |||
| 20 | + | Learned bool | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | // ValidateRemoteHostTarget rejects empty targets, surrounding/internal whitespace, | |||
| 24 | + | // and control characters. SSH target grammar is left to OpenSSH. | |||
| 25 | + | func ValidateRemoteHostTarget(target string) error { | |||
| 26 | + | if target == "" { | |||
| 27 | + | return fmt.Errorf("remote host target is empty") | |||
| 28 | + | } | |||
| 29 | + | if strings.TrimSpace(target) != target { | |||
| 30 | + | return fmt.Errorf("remote host target %q has surrounding whitespace", target) | |||
| 31 | + | } | |||
| 32 | + | for _, r := range target { | |||
| 33 | + | if unicode.IsSpace(r) || unicode.IsControl(r) { | |||
| 34 | + | return fmt.Errorf("remote host target %q contains whitespace or control characters", target) | |||
| 35 | + | } | |||
| 36 | + | } | |||
| 37 | + | return nil | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | // UniqueRemoteHostTargets returns a de-duplicated copy preserving first occurrence order. | |||
| 41 | + | func UniqueRemoteHostTargets(values []string) []string { | |||
| 42 | + | seen := make(map[string]struct{}, len(values)) | |||
| 43 | + | out := make([]string, 0, len(values)) | |||
| 44 | + | for _, value := range values { | |||
| 45 | + | if _, ok := seen[value]; ok { | |||
| 46 | + | continue | |||
| 47 | + | } | |||
| 48 | + | seen[value] = struct{}{} | |||
| 49 | + | out = append(out, value) | |||
| 50 | + | } | |||
| 51 | + | return out | |||
| 52 | + | } | |||
A internal/domain/remote_host_test.go +89 -0
| @@ -0,0 +1,89 @@ | |||
| 1 | + | package domain | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "slices" | |
| 5 | + | "testing" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func TestRemoteHostTargetValidation(t *testing.T) { | |
| 9 | + | tests := []struct { | |
| 10 | + | name string | |
| 11 | + | target string | |
| 12 | + | wantErr bool | |
| 13 | + | }{ | |
| 14 | + | {name: "alias", target: "arch"}, | |
| 15 | + | {name: "user at host", target: "build@mule"}, | |
| 16 | + | {name: "bracketed ipv6 with port", target: "user@[2001:db8::1]:2222"}, | |
| 17 | + | {name: "empty", target: "", wantErr: true}, | |
| 18 | + | {name: "leading whitespace", target: " arch", wantErr: true}, | |
| 19 | + | {name: "trailing whitespace", target: "arch ", wantErr: true}, | |
| 20 | + | {name: "internal whitespace", target: "build @mule", wantErr: true}, | |
| 21 | + | {name: "newline", target: "arch\n", wantErr: true}, | |
| 22 | + | {name: "tab", target: "arch\t", wantErr: true}, | |
| 23 | + | {name: "control byte", target: "arch\x00", wantErr: true}, | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | for _, tt := range tests { | |
| 27 | + | t.Run(tt.name, func(t *testing.T) { | |
| 28 | + | err := ValidateRemoteHostTarget(tt.target) | |
| 29 | + | if tt.wantErr && err == nil { | |
| 30 | + | t.Fatalf("ValidateRemoteHostTarget(%q) = nil, want error", tt.target) | |
| 31 | + | } | |
| 32 | + | if !tt.wantErr && err != nil { | |
| 33 | + | t.Fatalf("ValidateRemoteHostTarget(%q) = %v, want nil", tt.target, err) | |
| 34 | + | } | |
| 35 | + | }) | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | func TestRemoteHostUniqueTargets(t *testing.T) { | |
| 40 | + | tests := []struct { | |
| 41 | + | name string | |
| 42 | + | input []string | |
| 43 | + | want []string | |
| 44 | + | }{ | |
| 45 | + | { | |
| 46 | + | name: "preserves first occurrence order", | |
| 47 | + | input: []string{"arch", "build@mule", "arch", "mule"}, | |
| 48 | + | want: []string{"arch", "build@mule", "mule"}, | |
| 49 | + | }, | |
| 50 | + | { | |
| 51 | + | name: "empty input", | |
| 52 | + | input: nil, | |
| 53 | + | want: []string{}, | |
| 54 | + | }, | |
| 55 | + | { | |
| 56 | + | name: "no duplicates", | |
| 57 | + | input: []string{"alpha", "beta"}, | |
| 58 | + | want: []string{"alpha", "beta"}, | |
| 59 | + | }, | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | for _, tt := range tests { | |
| 63 | + | t.Run(tt.name, func(t *testing.T) { | |
| 64 | + | got := UniqueRemoteHostTargets(tt.input) | |
| 65 | + | if got == nil { | |
| 66 | + | t.Fatalf("UniqueRemoteHostTargets(%v) = nil, want non-nil slice", tt.input) | |
| 67 | + | } | |
| 68 | + | if !slices.Equal(got, tt.want) { | |
| 69 | + | t.Fatalf("UniqueRemoteHostTargets(%v) = %v, want %v", tt.input, got, tt.want) | |
| 70 | + | } | |
| 71 | + | }) | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | func TestRemoteHostConfigDefaults(t *testing.T) { | |
| 76 | + | cfg := Defaults() | |
| 77 | + | if !cfg.Remote.Enabled { | |
| 78 | + | t.Fatal("Defaults().Remote.Enabled = false, want true") | |
| 79 | + | } | |
| 80 | + | if !cfg.Remote.Remember { | |
| 81 | + | t.Fatal("Defaults().Remote.Remember = false, want true") | |
| 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) | |
| 88 | + | } | |
| 89 | + | } | |
| @@ -0,0 +1,89 @@ | |||||
| 1 | + | package domain | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "slices" | |||
| 5 | + | "testing" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | func TestRemoteHostTargetValidation(t *testing.T) { | |||
| 9 | + | tests := []struct { | |||
| 10 | + | name string | |||
| 11 | + | target string | |||
| 12 | + | wantErr bool | |||
| 13 | + | }{ | |||
| 14 | + | {name: "alias", target: "arch"}, | |||
| 15 | + | {name: "user at host", target: "build@mule"}, | |||
| 16 | + | {name: "bracketed ipv6 with port", target: "user@[2001:db8::1]:2222"}, | |||
| 17 | + | {name: "empty", target: "", wantErr: true}, | |||
| 18 | + | {name: "leading whitespace", target: " arch", wantErr: true}, | |||
| 19 | + | {name: "trailing whitespace", target: "arch ", wantErr: true}, | |||
| 20 | + | {name: "internal whitespace", target: "build @mule", wantErr: true}, | |||
| 21 | + | {name: "newline", target: "arch\n", wantErr: true}, | |||
| 22 | + | {name: "tab", target: "arch\t", wantErr: true}, | |||
| 23 | + | {name: "control byte", target: "arch\x00", wantErr: true}, | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | for _, tt := range tests { | |||
| 27 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 28 | + | err := ValidateRemoteHostTarget(tt.target) | |||
| 29 | + | if tt.wantErr && err == nil { | |||
| 30 | + | t.Fatalf("ValidateRemoteHostTarget(%q) = nil, want error", tt.target) | |||
| 31 | + | } | |||
| 32 | + | if !tt.wantErr && err != nil { | |||
| 33 | + | t.Fatalf("ValidateRemoteHostTarget(%q) = %v, want nil", tt.target, err) | |||
| 34 | + | } | |||
| 35 | + | }) | |||
| 36 | + | } | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | func TestRemoteHostUniqueTargets(t *testing.T) { | |||
| 40 | + | tests := []struct { | |||
| 41 | + | name string | |||
| 42 | + | input []string | |||
| 43 | + | want []string | |||
| 44 | + | }{ | |||
| 45 | + | { | |||
| 46 | + | name: "preserves first occurrence order", | |||
| 47 | + | input: []string{"arch", "build@mule", "arch", "mule"}, | |||
| 48 | + | want: []string{"arch", "build@mule", "mule"}, | |||
| 49 | + | }, | |||
| 50 | + | { | |||
| 51 | + | name: "empty input", | |||
| 52 | + | input: nil, | |||
| 53 | + | want: []string{}, | |||
| 54 | + | }, | |||
| 55 | + | { | |||
| 56 | + | name: "no duplicates", | |||
| 57 | + | input: []string{"alpha", "beta"}, | |||
| 58 | + | want: []string{"alpha", "beta"}, | |||
| 59 | + | }, | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | for _, tt := range tests { | |||
| 63 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 64 | + | got := UniqueRemoteHostTargets(tt.input) | |||
| 65 | + | if got == nil { | |||
| 66 | + | t.Fatalf("UniqueRemoteHostTargets(%v) = nil, want non-nil slice", tt.input) | |||
| 67 | + | } | |||
| 68 | + | if !slices.Equal(got, tt.want) { | |||
| 69 | + | t.Fatalf("UniqueRemoteHostTargets(%v) = %v, want %v", tt.input, got, tt.want) | |||
| 70 | + | } | |||
| 71 | + | }) | |||
| 72 | + | } | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | func TestRemoteHostConfigDefaults(t *testing.T) { | |||
| 76 | + | cfg := Defaults() | |||
| 77 | + | if !cfg.Remote.Enabled { | |||
| 78 | + | t.Fatal("Defaults().Remote.Enabled = false, want true") | |||
| 79 | + | } | |||
| 80 | + | if !cfg.Remote.Remember { | |||
| 81 | + | t.Fatal("Defaults().Remote.Remember = false, want true") | |||
| 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) | |||
| 88 | + | } | |||
| 89 | + | } | |||