From a89754980d873952af31355f928f2fdbbd2b13ef Mon Sep 17 00:00:00 2001 From: lord-helicon <237113697+lord-helicon@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:50:15 +0200 Subject: [PATCH] fix(parser): keep negated Host patterns when saving The ssh_config parser strips the leading "!" from Pattern.Str and only tracks the negation internally, so every save rewrote "Host * !foo" as "Host * foo", inverting its meaning. It also made "Host !foo" show up as a server named foo. Restore the "!" right after parsing. --- .../data/ssh_config_file/config_io.go | 16 +++++++ .../data/ssh_config_file/helpers_test.go | 45 +++++++++++++++++++ .../ssh_config_file/negated_patterns_test.go | 45 +++++++++++++++++++ 3 files changed, 106 insertions(+) create mode 100644 internal/adapters/data/ssh_config_file/helpers_test.go create mode 100644 internal/adapters/data/ssh_config_file/negated_patterns_test.go diff --git a/internal/adapters/data/ssh_config_file/config_io.go b/internal/adapters/data/ssh_config_file/config_io.go index b5a5da1c..fb6b0902 100644 --- a/internal/adapters/data/ssh_config_file/config_io.go +++ b/internal/adapters/data/ssh_config_file/config_io.go @@ -43,10 +43,26 @@ func (r *Repository) loadConfig() (*ssh_config.Config, error) { if err != nil { return nil, fmt.Errorf("failed to decode config: %w", err) } + restoreNegatedPatterns(cfg) return cfg, nil } +// restoreNegatedPatterns puts the leading "!" back on negated Host patterns. +// The parser strips it from Pattern.Str and keeps the negation internally, so +// without this every save would turn "Host * !foo" into "Host * foo". +func restoreNegatedPatterns(cfg *ssh_config.Config) { + for _, host := range cfg.Hosts { + for _, pattern := range host.Patterns { + // A pattern always matches its own text unless it is negated. + single := &ssh_config.Host{Patterns: []*ssh_config.Pattern{pattern}} + if !single.Matches(pattern.Str) { + pattern.Str = "!" + pattern.Str + } + } + } +} + // saveConfig writes the SSH config back to the file with atomic operations and backup management. func (r *Repository) saveConfig(cfg *ssh_config.Config) error { configDir := filepath.Dir(r.configPath) diff --git a/internal/adapters/data/ssh_config_file/helpers_test.go b/internal/adapters/data/ssh_config_file/helpers_test.go new file mode 100644 index 00000000..a4c84589 --- /dev/null +++ b/internal/adapters/data/ssh_config_file/helpers_test.go @@ -0,0 +1,45 @@ +// Copyright 2025. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ssh_config_file + +import ( + "os" + "path/filepath" + "testing" + + "go.uber.org/zap" +) + +func newTestRepo(t *testing.T, configPath string) *Repository { + t.Helper() + meta := filepath.Join(t.TempDir(), "metadata.json") + return NewRepository(zap.NewNop().Sugar(), configPath, meta).(*Repository) +} + +func writeFile(t *testing.T, path, content string) { + t.Helper() + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } +} + +func readFile(t *testing.T, path string) string { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return string(data) +} diff --git a/internal/adapters/data/ssh_config_file/negated_patterns_test.go b/internal/adapters/data/ssh_config_file/negated_patterns_test.go new file mode 100644 index 00000000..c7b1bac6 --- /dev/null +++ b/internal/adapters/data/ssh_config_file/negated_patterns_test.go @@ -0,0 +1,45 @@ +// Copyright 2025. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ssh_config_file + +import ( + "path/filepath" + "testing" + + "github.com/Adembc/lazyssh/internal/core/domain" +) + +func TestSaveKeepsNegatedPatterns(t *testing.T) { + path := filepath.Join(t.TempDir(), "config") + writeFile(t, path, "Host !bastion\n User nobody\n\nHost * !lab-? !b\n User other\n\n") + + repo := newTestRepo(t, path) + servers, err := repo.ListServers("") + if err != nil { + t.Fatal(err) + } + if len(servers) != 0 { + t.Errorf("negated patterns must not be listed as servers, got %+v", servers) + } + if err := repo.AddServer(domain.Server{Alias: "b", Host: "10.0.0.2"}); err != nil { + t.Fatal(err) + } + + want := "Host !bastion\n User nobody\n\nHost * !lab-? !b\n User other\n\n" + + "Host b #Added by lazyssh\n HostName 10.0.0.2\n" + if got := readFile(t, path); got != want { + t.Errorf("unexpected config:\n--- got ---\n%s\n--- want ---\n%s", got, want) + } +}