From 67987e6242815261741f5d7543a11dffc6a6e477 Mon Sep 17 00:00:00 2001 From: Gu1llaum-3 Date: Sat, 6 Sep 2025 23:36:12 +0200 Subject: [PATCH] refactor: move SSH backups to ~/.config/sshm/backups/ --- README.md | 23 +++++++ internal/config/ssh.go | 56 +++++++++++++++-- internal/config/ssh_test.go | 116 ++++++++++++++++++++++++++++++++++++ 3 files changed, 191 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 8c02368..7033b23 100644 --- a/README.md +++ b/README.md @@ -243,6 +243,29 @@ sshm --version sshm --help ``` +### Backup Configuration + +SSHM automatically creates backups of your SSH configuration files before making any changes to ensure your configurations are safe. + +**Backup Location:** +- **Unix/Linux/macOS**: `~/.config/sshm/backups/` (or `$XDG_CONFIG_HOME/sshm/backups/` if set) +- **Windows**: `%APPDATA%\sshm\backups\` (fallback: `%USERPROFILE%\.config\sshm\backups\`) + +**Key Features:** +- Automatic backup before any modification +- One backup per file (overwrites previous backup) +- Stored separately to avoid SSH Include conflicts +- Easy manual recovery if needed + +**Quick Recovery:** +```bash +# Unix/Linux/macOS +cp ~/.config/sshm/backups/config.backup ~/.ssh/config + +# Windows +copy "%APPDATA%\sshm\backups\config.backup" "%USERPROFILE%\.ssh\config" +``` + ### Configuration File Options By default, SSHM uses the standard SSH configuration file at `~/.ssh/config`. You can specify a different configuration file using the `-c` flag: diff --git a/internal/config/ssh.go b/internal/config/ssh.go index 1e2b88b..993afdb 100644 --- a/internal/config/ssh.go +++ b/internal/config/ssh.go @@ -40,6 +40,36 @@ func GetDefaultSSHConfigPath() (string, error) { } } +// GetSSHMBackupDir returns the SSHM backup directory +func GetSSHMBackupDir() (string, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", err + } + + var configDir string + switch runtime.GOOS { + case "windows": + // Use %APPDATA%/sshm on Windows + appData := os.Getenv("APPDATA") + if appData != "" { + configDir = filepath.Join(appData, "sshm") + } else { + configDir = filepath.Join(homeDir, ".config", "sshm") + } + default: + // Use XDG Base Directory specification + xdgConfigDir := os.Getenv("XDG_CONFIG_HOME") + if xdgConfigDir != "" { + configDir = filepath.Join(xdgConfigDir, "sshm") + } else { + configDir = filepath.Join(homeDir, ".config", "sshm") + } + } + + return filepath.Join(configDir, "backups"), nil +} + // GetSSHDirectory returns the .ssh directory path func GetSSHDirectory() (string, error) { homeDir, err := os.UserHomeDir() @@ -67,9 +97,23 @@ func ensureSSHDirectory() error { // configMutex protects SSH config file operations from race conditions var configMutex sync.Mutex -// backupConfig creates a backup of the SSH config file +// backupConfig creates a backup of the SSH config file in ~/.config/sshm/backups/ func backupConfig(configPath string) error { - backupPath := configPath + ".backup" + // Get backup directory and ensure it exists + backupDir, err := GetSSHMBackupDir() + if err != nil { + return fmt.Errorf("failed to get backup directory: %w", err) + } + + if err := os.MkdirAll(backupDir, 0755); err != nil { + return fmt.Errorf("failed to create backup directory: %w", err) + } + + // Create simple backup filename (overwrites previous backup) + filename := filepath.Base(configPath) + backupPath := filepath.Join(backupDir, filename+".backup") + + // Copy file src, err := os.Open(configPath) if err != nil { return err @@ -82,8 +126,12 @@ func backupConfig(configPath string) error { } defer dst.Close() - _, err = io.Copy(dst, src) - return err + if _, err = io.Copy(dst, src); err != nil { + return err + } + + // Set appropriate permissions + return os.Chmod(backupPath, 0600) } // ParseSSHConfig parses the SSH config file and returns the list of hosts diff --git a/internal/config/ssh_test.go b/internal/config/ssh_test.go index 4abbbc3..ee610f3 100644 --- a/internal/config/ssh_test.go +++ b/internal/config/ssh_test.go @@ -452,6 +452,122 @@ Include *.conf } } +func TestBackupConfigToSSHMDirectory(t *testing.T) { + // Create temporary directory for test files + tempDir := t.TempDir() + + // Override the home directory for this test + originalHome := os.Getenv("HOME") + if originalHome == "" { + originalHome = os.Getenv("USERPROFILE") // Windows + } + + // Set test home directory + os.Setenv("HOME", tempDir) + defer os.Setenv("HOME", originalHome) + + // Create a test SSH config file + sshDir := filepath.Join(tempDir, ".ssh") + err := os.MkdirAll(sshDir, 0700) + if err != nil { + t.Fatalf("Failed to create .ssh directory: %v", err) + } + + configPath := filepath.Join(sshDir, "config") + configContent := `Host test-host + HostName test.example.com + User testuser +` + + err = os.WriteFile(configPath, []byte(configContent), 0600) + if err != nil { + t.Fatalf("Failed to create config file: %v", err) + } + + // Test backup creation + err = backupConfig(configPath) + if err != nil { + t.Fatalf("backupConfig() error = %v", err) + } + + // Verify backup directory was created + backupDir, err := GetSSHMBackupDir() + if err != nil { + t.Fatalf("GetSSHMBackupDir() error = %v", err) + } + + if _, err := os.Stat(backupDir); os.IsNotExist(err) { + t.Errorf("Backup directory was not created: %s", backupDir) + } + + // Verify backup file was created + files, err := os.ReadDir(backupDir) + if err != nil { + t.Fatalf("Failed to read backup directory: %v", err) + } + + if len(files) != 1 { + t.Errorf("Expected 1 backup file, got %d", len(files)) + } + + if len(files) > 0 { + backupFile := files[0] + expectedName := "config.backup" + if backupFile.Name() != expectedName { + t.Errorf("Backup file has unexpected name: got %s, want %s", backupFile.Name(), expectedName) + } + + // Verify backup content + backupContent, err := os.ReadFile(filepath.Join(backupDir, backupFile.Name())) + if err != nil { + t.Fatalf("Failed to read backup file: %v", err) + } + + if string(backupContent) != configContent { + t.Errorf("Backup content doesn't match original") + } + } + + // Test that subsequent backups overwrite the previous one + newConfigContent := `Host test-host-updated + HostName updated.example.com + User updateduser +` + + err = os.WriteFile(configPath, []byte(newConfigContent), 0600) + if err != nil { + t.Fatalf("Failed to update config file: %v", err) + } + + // Create second backup + err = backupConfig(configPath) + if err != nil { + t.Fatalf("Second backupConfig() error = %v", err) + } + + // Verify still only one backup file exists + files, err = os.ReadDir(backupDir) + if err != nil { + t.Fatalf("Failed to read backup directory after second backup: %v", err) + } + + if len(files) != 1 { + t.Errorf("Expected still 1 backup file after overwrite, got %d", len(files)) + } + + // Verify backup content was updated + if len(files) > 0 { + backupContent, err := os.ReadFile(filepath.Join(backupDir, files[0].Name())) + if err != nil { + t.Fatalf("Failed to read updated backup file: %v", err) + } + + if string(backupContent) != newConfigContent { + t.Errorf("Updated backup content doesn't match new config content") + } + } +} + func TestFindHostInAllConfigs(t *testing.T) { // Create temporary directory for test files tempDir := t.TempDir()