Merge pull request #155 from bnema/fix/remote-state-store
fix(remote): keep host state out of config
8 files · +2 -839
M internal/adapters/config/config.go +1 -26
| @@ -21,13 +21,11 @@ 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(`^\[([^\]]+)\]$`) | |
| 25 | 24 | ) | |
| 26 | 25 | ||
| 27 | 26 | // Parse reads vev's flat action = value config format. Duplicate action keys | |
| 28 | 27 | // 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 | |
| 30 | - | // accepts enabled/remember/hosts with TOML-style true/false values. | |
| 28 | + | // is preserved for binding conflict resolution. | |
| 31 | 29 | func Parse(r io.Reader) (domain.Config, []domain.Warning, error) { | |
| 32 | 30 | cfg := domain.Defaults() | |
| 33 | 31 | var warnings []domain.Warning | |
| @@ -37,8 +35,6 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) { | |||
| 37 | 35 | seenPaletteKeys := make(map[string]bool) | |
| 38 | 36 | seenNavKeys := make(map[string]bool) | |
| 39 | 37 | seenTabsKeys := make(map[string]bool) | |
| 40 | - | seenRemoteKeys := make(map[string]bool) | |
| 41 | - | section := "" | |
| 42 | 38 | ||
| 43 | 39 | scanner := bufio.NewScanner(r) | |
| 44 | 40 | lineNo := 0 | |
| @@ -53,17 +49,6 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) { | |||
| 53 | 49 | continue | |
| 54 | 50 | } | |
| 55 | 51 | ||
| 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 | - | ||
| 67 | 52 | key, value, ok := strings.Cut(line, "=") | |
| 68 | 53 | if !ok { | |
| 69 | 54 | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: "missing '='"}) | |
| @@ -76,16 +61,6 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) { | |||
| 76 | 61 | continue | |
| 77 | 62 | } | |
| 78 | 63 | ||
| 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 | - | ||
| 89 | 64 | switch { | |
| 90 | 65 | case key == "theme": | |
| 91 | 66 | mode, ok := parseTheme(value) | |
| @@ -21,13 +21,11 @@ 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(`^\[([^\]]+)\]$`) | |||
| 25 | ) | 24 | ) | ||
| 26 | 25 | ||||
| 27 | // Parse reads vev's flat action = value config format. Duplicate action keys | 26 | // 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 | 27 | // 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 | 28 | + | // is preserved for binding conflict resolution. |
| 30 | - | // accepts enabled/remember/hosts with TOML-style true/false values. | |||
| 31 | func Parse(r io.Reader) (domain.Config, []domain.Warning, error) { | 29 | func Parse(r io.Reader) (domain.Config, []domain.Warning, error) { | ||
| 32 | cfg := domain.Defaults() | 30 | cfg := domain.Defaults() | ||
| 33 | var warnings []domain.Warning | 31 | var warnings []domain.Warning | ||
| @@ -37,8 +35,6 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) { | |||||
| 37 | seenPaletteKeys := make(map[string]bool) | 35 | seenPaletteKeys := make(map[string]bool) | ||
| 38 | seenNavKeys := make(map[string]bool) | 36 | seenNavKeys := make(map[string]bool) | ||
| 39 | seenTabsKeys := make(map[string]bool) | 37 | seenTabsKeys := make(map[string]bool) | ||
| 40 | - | seenRemoteKeys := make(map[string]bool) | |||
| 41 | - | section := "" | |||
| 42 | 38 | ||||
| 43 | scanner := bufio.NewScanner(r) | 39 | scanner := bufio.NewScanner(r) | ||
| 44 | lineNo := 0 | 40 | lineNo := 0 | ||
| @@ -53,17 +49,6 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) { | |||||
| 53 | continue | 49 | continue | ||
| 54 | } | 50 | } | ||
| 55 | 51 | ||||
| 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 | - | ||||
| 67 | key, value, ok := strings.Cut(line, "=") | 52 | key, value, ok := strings.Cut(line, "=") | ||
| 68 | if !ok { | 53 | if !ok { | ||
| 69 | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: "missing '='"}) | 54 | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: "missing '='"}) | ||
| @@ -76,16 +61,6 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) { | |||||
| 76 | continue | 61 | continue | ||
| 77 | } | 62 | } | ||
| 78 | 63 | ||||
| 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 | - | ||||
| 89 | switch { | 64 | switch { | ||
| 90 | case key == "theme": | 65 | case key == "theme": | ||
| 91 | mode, ok := parseTheme(value) | 66 | mode, ok := parseTheme(value) | ||
M internal/adapters/config/config_test.go +0 -3
| @@ -358,9 +358,6 @@ 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 | - | } | |
| 364 | 361 | got, warnings, err := Parse(strings.NewReader(tt.input)) | |
| 365 | 362 | if err != nil { | |
| 366 | 363 | t.Fatalf("Parse() error = %v", err) | |
| @@ -358,9 +358,6 @@ 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 | - | } | |||
| 364 | got, warnings, err := Parse(strings.NewReader(tt.input)) | 361 | got, warnings, err := Parse(strings.NewReader(tt.input)) | ||
| 365 | if err != nil { | 362 | if err != nil { | ||
| 366 | t.Fatalf("Parse() error = %v", err) | 363 | t.Fatalf("Parse() error = %v", err) | ||
D internal/adapters/config/remote.go +0 -328
| @@ -1,328 +0,0 @@ | |||
| 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 | - | } | |
| @@ -1,328 +0,0 @@ | |||||
| 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 | - | } | |||
D internal/adapters/config/remote_test.go +0 -452
| @@ -1,452 +0,0 @@ | |||
| 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 | - | } | |
| @@ -1,452 +0,0 @@ | |||||
| 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 +0 -6
| @@ -98,7 +98,6 @@ type Config struct { | |||
| 98 | 98 | Floating FloatingConfig | |
| 99 | 99 | Nav NavConfig | |
| 100 | 100 | Tabs TabsConfig | |
| 101 | - | Remote RemoteConfig | |
| 102 | 101 | } | |
| 103 | 102 | ||
| 104 | 103 | // Warning describes a non-fatal config problem. Parsers and reloaders should | |
| @@ -136,10 +135,5 @@ func Defaults() Config { | |||
| 136 | 135 | Tabs: TabsConfig{ | |
| 137 | 136 | TerminalTitle: true, | |
| 138 | 137 | }, | |
| 139 | - | Remote: RemoteConfig{ | |
| 140 | - | Enabled: true, | |
| 141 | - | Remember: true, | |
| 142 | - | Hosts: []string{}, | |
| 143 | - | }, | |
| 144 | 138 | } | |
| 145 | 139 | } | |
| @@ -98,7 +98,6 @@ type Config struct { | |||||
| 98 | Floating FloatingConfig | 98 | Floating FloatingConfig | ||
| 99 | Nav NavConfig | 99 | Nav NavConfig | ||
| 100 | Tabs TabsConfig | 100 | Tabs TabsConfig | ||
| 101 | - | Remote RemoteConfig | |||
| 102 | } | 101 | } | ||
| 103 | 102 | ||||
| 104 | // Warning describes a non-fatal config problem. Parsers and reloaders should | 103 | // Warning describes a non-fatal config problem. Parsers and reloaders should | ||
| @@ -136,10 +135,5 @@ func Defaults() Config { | |||||
| 136 | Tabs: TabsConfig{ | 135 | Tabs: TabsConfig{ | ||
| 137 | TerminalTitle: true, | 136 | TerminalTitle: true, | ||
| 138 | }, | 137 | }, | ||
| 139 | - | Remote: RemoteConfig{ | |||
| 140 | - | Enabled: true, | |||
| 141 | - | Remember: true, | |||
| 142 | - | Hosts: []string{}, | |||
| 143 | - | }, | |||
| 144 | } | 138 | } | ||
| 145 | } | 139 | } | ||
M internal/domain/remote_host.go +0 -7
| @@ -6,13 +6,6 @@ import ( | |||
| 6 | 6 | "unicode" | |
| 7 | 7 | ) | |
| 8 | 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 | 9 | // RemoteHost is a merged remote host entry with its source markers. | |
| 17 | 10 | type RemoteHost struct { | |
| 18 | 11 | Target string | |
| @@ -6,13 +6,6 @@ import ( | |||||
| 6 | "unicode" | 6 | "unicode" | ||
| 7 | ) | 7 | ) | ||
| 8 | 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. | 9 | // RemoteHost is a merged remote host entry with its source markers. | ||
| 17 | type RemoteHost struct { | 10 | type RemoteHost struct { | ||
| 18 | Target string | 11 | Target string | ||
M internal/domain/remote_host_test.go +0 -16
| @@ -71,19 +71,3 @@ func TestRemoteHostUniqueTargets(t *testing.T) { | |||
| 71 | 71 | }) | |
| 72 | 72 | } | |
| 73 | 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 | - | } | |
| @@ -71,19 +71,3 @@ func TestRemoteHostUniqueTargets(t *testing.T) { | |||||
| 71 | }) | 71 | }) | ||
| 72 | } | 72 | } | ||
| 73 | } | 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 | - | } | |||
M scripts/debug-remote-attach.sh +1 -1
| @@ -80,7 +80,7 @@ diagnostic_files=() | |||
| 80 | 80 | # and common key material labels from bundles that may be shared. | |
| 81 | 81 | redact() { | |
| 82 | 82 | sed -E \ | |
| 83 | - | -e "s|${target//\/\\/\\}|[remote]|g" \ | |
| 83 | + | -e "s|${target//\/\\/\\}|[target]|g" \ | |
| 84 | 84 | -e "s|${HOME//\/\\/\\}|[home]|g" \ | |
| 85 | 85 | -e 's/([[:alnum:]_.-]+@)?([[:alnum:]_.-]+\.)+[[:alpha:]]{2,}/[host]/g' \ | |
| 86 | 86 | -e 's/([0-9]{1,3}\.){3}[0-9]{1,3}/[address]/g' \ | |
| @@ -80,7 +80,7 @@ diagnostic_files=() | |||||
| 80 | # and common key material labels from bundles that may be shared. | 80 | # and common key material labels from bundles that may be shared. | ||
| 81 | redact() { | 81 | redact() { | ||
| 82 | sed -E \ | 82 | sed -E \ | ||
| 83 | - | -e "s|${target//\/\\/\\}|[remote]|g" \ | 83 | + | -e "s|${target//\/\\/\\}|[target]|g" \ |
| 84 | -e "s|${HOME//\/\\/\\}|[home]|g" \ | 84 | -e "s|${HOME//\/\\/\\}|[home]|g" \ | ||
| 85 | -e 's/([[:alnum:]_.-]+@)?([[:alnum:]_.-]+\.)+[[:alpha:]]{2,}/[host]/g' \ | 85 | -e 's/([[:alnum:]_.-]+@)?([[:alnum:]_.-]+\.)+[[:alpha:]]{2,}/[host]/g' \ | ||
| 86 | -e 's/([0-9]{1,3}\.){3}[0-9]{1,3}/[address]/g' \ | 86 | -e 's/([0-9]{1,3}\.){3}[0-9]{1,3}/[address]/g' \ | ||