From da2c083e85c092a8a2b5b9b2a4bf96499e8d8699 Mon Sep 17 00:00:00 2001 From: lord-helicon <237113697+lord-helicon@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:49:55 +0200 Subject: [PATCH] fix(config): write through a symlinked ~/.ssh/config Saving renamed the temp file over ~/.ssh/config itself, replacing a symlink (e.g. into a dotfiles repo) with a regular file and leaving the real file stale. Resolve the link first and atomically replace its target instead. Backups still go next to the link in ~/.ssh. Fixes #96 --- .../data/ssh_config_file/config_io.go | 22 ++++++- .../ssh_config_file/config_symlink_test.go | 61 +++++++++++++++++++ .../data/ssh_config_file/file_system.go | 6 ++ .../data/ssh_config_file/helpers_test.go | 45 ++++++++++++++ 4 files changed, 132 insertions(+), 2 deletions(-) create mode 100644 internal/adapters/data/ssh_config_file/config_symlink_test.go create mode 100644 internal/adapters/data/ssh_config_file/helpers_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..35c50aa2 100644 --- a/internal/adapters/data/ssh_config_file/config_io.go +++ b/internal/adapters/data/ssh_config_file/config_io.go @@ -49,7 +49,12 @@ func (r *Repository) loadConfig() (*ssh_config.Config, error) { // 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) + // Write through a symlinked config (e.g. managed by a dotfiles repo) instead of replacing the link. + targetPath, err := r.resolveConfigPath() + if err != nil { + return fmt.Errorf("failed to resolve config path: %w", err) + } + configDir := filepath.Dir(targetPath) tempFile, err := r.createTempFile(configDir) if err != nil { @@ -75,7 +80,7 @@ func (r *Repository) saveConfig(cfg *ssh_config.Config) error { return fmt.Errorf("failed to create backup: %w", err) } - if err := r.fileSystem.Rename(tempFile, r.configPath); err != nil { + if err := r.fileSystem.Rename(tempFile, targetPath); err != nil { return fmt.Errorf("failed to atomically replace config file: %w", err) } @@ -83,6 +88,19 @@ func (r *Repository) saveConfig(cfg *ssh_config.Config) error { return nil } +// resolveConfigPath returns the file the config path points to, following symlinks. +// A config that does not exist yet resolves to the configured path. +func (r *Repository) resolveConfigPath() (string, error) { + resolved, err := r.fileSystem.EvalSymlinks(r.configPath) + if err != nil { + if r.fileSystem.IsNotExist(err) { + return r.configPath, nil + } + return "", err + } + return resolved, nil +} + // writeConfigToFile writes the SSH config content to the specified file func (r *Repository) writeConfigToFile(filePath string, cfg *ssh_config.Config) error { file, err := r.fileSystem.OpenFile(filePath, os.O_WRONLY|os.O_TRUNC, SSHConfigPerms) diff --git a/internal/adapters/data/ssh_config_file/config_symlink_test.go b/internal/adapters/data/ssh_config_file/config_symlink_test.go new file mode 100644 index 00000000..1c29432a --- /dev/null +++ b/internal/adapters/data/ssh_config_file/config_symlink_test.go @@ -0,0 +1,61 @@ +// 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" + "strings" + "testing" + + "github.com/Adembc/lazyssh/internal/core/domain" +) + +func TestAddServerKeepsSymlinkedConfig(t *testing.T) { + sshDir := t.TempDir() + dotfiles := t.TempDir() + target := filepath.Join(dotfiles, "ssh_config") + link := filepath.Join(sshDir, "config") + writeFile(t, target, "Host a\n HostName 10.0.0.1\n") + if err := os.Symlink(target, link); err != nil { + t.Fatal(err) + } + + repo := newTestRepo(t, link) + if err := repo.AddServer(domain.Server{Alias: "b", Host: "10.0.0.2"}); err != nil { + t.Fatal(err) + } + + fi, err := os.Lstat(link) + if err != nil { + t.Fatal(err) + } + if fi.Mode()&os.ModeSymlink == 0 { + t.Fatal("config symlink was replaced by a regular file") + } + if got := readFile(t, target); !strings.Contains(got, "Host b") { + t.Errorf("symlink target was not updated:\n%s", got) + } + if _, err := os.Stat(filepath.Join(sshDir, OriginalBackupName)); err != nil { + t.Errorf("expected original backup next to the symlink: %v", err) + } + entries, err := os.ReadDir(dotfiles) + if err != nil { + t.Fatal(err) + } + if len(entries) != 1 { + t.Errorf("expected only the config in the symlink target dir, got %d entries", len(entries)) + } +} diff --git a/internal/adapters/data/ssh_config_file/file_system.go b/internal/adapters/data/ssh_config_file/file_system.go index a0f4099a..fd297652 100644 --- a/internal/adapters/data/ssh_config_file/file_system.go +++ b/internal/adapters/data/ssh_config_file/file_system.go @@ -17,6 +17,7 @@ package ssh_config_file import ( "io" "os" + "path/filepath" ) // FileSystem interface for file operations to enable testing. @@ -30,6 +31,7 @@ type FileSystem interface { Chmod(path string, perms os.FileMode) error OpenFile(path string, i int, perms os.FileMode) (*os.File, error) ReadDir(dir string) ([]os.DirEntry, error) + EvalSymlinks(path string) (string, error) } // DefaultFileSystem implements FileSystem using standard os package. @@ -73,3 +75,7 @@ func (fs DefaultFileSystem) OpenFile(path string, i int, perms os.FileMode) (*os func (fs DefaultFileSystem) ReadDir(dir string) ([]os.DirEntry, error) { return os.ReadDir(dir) } + +func (fs DefaultFileSystem) EvalSymlinks(path string) (string, error) { + return filepath.EvalSymlinks(path) +} 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) +}