Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions internal/adapters/data/ssh_config_file/config_io.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -75,14 +80,27 @@ 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)
}

r.logger.Infof("SSH config successfully updated: %s", r.configPath)
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)
Expand Down
61 changes: 61 additions & 0 deletions internal/adapters/data/ssh_config_file/config_symlink_test.go
Original file line number Diff line number Diff line change
@@ -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))
}
}
6 changes: 6 additions & 0 deletions internal/adapters/data/ssh_config_file/file_system.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ package ssh_config_file
import (
"io"
"os"
"path/filepath"
)

// FileSystem interface for file operations to enable testing.
Expand All @@ -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.
Expand Down Expand Up @@ -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)
}
45 changes: 45 additions & 0 deletions internal/adapters/data/ssh_config_file/helpers_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading