diff --git a/pkg/config/backup.go b/pkg/config/backup.go new file mode 100644 index 0000000..3b6e175 --- /dev/null +++ b/pkg/config/backup.go @@ -0,0 +1,107 @@ +package config + +import ( + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "time" +) + +// BackupFileMap holds path association of a backed up config file. +type BackupFileMap struct { + OriginalPath string `json:"original_path"` + BackupName string `json:"backup_name"` +} + +// BackupMetadata preserves information about the original source configurations. +type BackupMetadata struct { + Timestamp string `json:"timestamp"` + Files []BackupFileMap `json:"files"` +} + +// EnsureFirstRunBackup checks if a pre-tusshi backup already exists. +// If not, it creates a pristine backup of all configuration files in m.FileOrder +// inside a pre-tusshi directory next to the primary configuration. +// It checks existence based on the metadata file. +func (m *Manager) EnsureFirstRunBackup() error { + backupDir := filepath.Join(filepath.Dir(m.PrimaryPath), "pre-tusshi") + metaPath := filepath.Join(backupDir, "metadata.json") + + if _, err := os.Stat(metaPath); err == nil { + return nil // backup already exists + } + + var existingFiles []string + for _, path := range m.FileOrder { + if path == "" { + continue + } + if _, err := os.Stat(path); err == nil { + existingFiles = append(existingFiles, path) + } + } + + // enforce secure, user-only read/write access to the backup folder + if err := os.MkdirAll(backupDir, 0700); err != nil { + return fmt.Errorf("failed to create backup directory: %w", err) + } + + meta := BackupMetadata{ + Timestamp: time.Now().Format(time.RFC3339), + Files: make([]BackupFileMap, 0), + } + + for i, srcPath := range existingFiles { + baseName := filepath.Base(srcPath) + backupName := fmt.Sprintf("%s_%d", baseName, i) + dstPath := filepath.Join(backupDir, backupName) + + if err := backupFile(srcPath, dstPath); err != nil { + return fmt.Errorf("failed to backup file %q: %w", srcPath, err) + } + + meta.Files = append(meta.Files, BackupFileMap{ + OriginalPath: srcPath, + BackupName: backupName, + }) + } + + metaBytes, err := json.MarshalIndent(meta, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal backup metadata: %w", err) + } + + // enforce secure user-only access on the metadata file + if err := os.WriteFile(metaPath, metaBytes, 0600); err != nil { + return fmt.Errorf("failed to write backup metadata: %w", err) + } + + return nil +} + +// backupFile copies the content of a file to the destination with 0600 permissions. +func backupFile(src, dst string) error { + srcFile, err := os.Open(filepath.Clean(src)) + if err != nil { + return err + } + defer func() { + _ = srcFile.Close() + }() + + dstFile, err := os.OpenFile(filepath.Clean(dst), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) + if err != nil { + return err + } + defer func() { + _ = dstFile.Close() + }() + + if _, err := io.Copy(dstFile, srcFile); err != nil { + return err + } + + return dstFile.Sync() +} diff --git a/pkg/config/backup_test.go b/pkg/config/backup_test.go new file mode 100644 index 0000000..38fdb1a --- /dev/null +++ b/pkg/config/backup_test.go @@ -0,0 +1,133 @@ +package config + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestEnsureFirstRunBackup tests the creation of a pre-tusshi backup on the first run. +func TestEnsureFirstRunBackup(t *testing.T) { + t.Run("successful backup of primary and includes", func(t *testing.T) { + tmpDir := t.TempDir() + primaryPath := filepath.Join(tmpDir, "config") + includePath := filepath.Join(tmpDir, "include_config") + + err := os.WriteFile(primaryPath, []byte("Host test\n HostName 1.2.3.4\n"), 0600) + assert.NoError(t, err) + + err = os.WriteFile(includePath, []byte("Host include\n HostName 5.6.7.8\n"), 0600) + assert.NoError(t, err) + + mgr := NewManager(primaryPath) + mgr.FileOrder = []string{primaryPath, includePath} + + err = mgr.EnsureFirstRunBackup() + assert.NoError(t, err) + + backupDir := filepath.Join(tmpDir, "pre-tusshi") + assert.DirExists(t, backupDir) + + // check folder permissions (must be 0700 on Unix) + info, err := os.Stat(backupDir) + assert.NoError(t, err) + assert.Equal(t, os.ModeDir|0700, info.Mode().Perm()|os.ModeDir) + + metaPath := filepath.Join(backupDir, "metadata.json") + assert.FileExists(t, metaPath) + + // #nosec G304 - metaPath is a dynamic test path inside a temporary directory + metaBytes, err := os.ReadFile(metaPath) + assert.NoError(t, err) + + var meta BackupMetadata + err = json.Unmarshal(metaBytes, &meta) + assert.NoError(t, err) + + assert.NotEmpty(t, meta.Timestamp) + assert.Len(t, meta.Files, 2) + + // verify metadata content and permissions of backed up files + for _, f := range meta.Files { + backedUpPath := filepath.Join(backupDir, f.BackupName) + assert.FileExists(t, backedUpPath) + + fileInfo, err := os.Stat(backedUpPath) + assert.NoError(t, err) + assert.Equal(t, os.FileMode(0600), fileInfo.Mode().Perm()) + + // #nosec G304 - testing with dynamic test file path + originalContent, err := os.ReadFile(f.OriginalPath) + assert.NoError(t, err) + // #nosec G304 - testing with dynamic test file path + backupContent, err := os.ReadFile(backedUpPath) + assert.NoError(t, err) + assert.Equal(t, originalContent, backupContent) + } + }) + + t.Run("consecutive run prevents redundant backup", func(t *testing.T) { + tmpDir := t.TempDir() + primaryPath := filepath.Join(tmpDir, "config") + + err := os.WriteFile(primaryPath, []byte("Host first\n"), 0600) + assert.NoError(t, err) + + mgr := NewManager(primaryPath) + mgr.FileOrder = []string{primaryPath} + + err = mgr.EnsureFirstRunBackup() + assert.NoError(t, err) + + backupDir := filepath.Join(tmpDir, "pre-tusshi") + metaPath := filepath.Join(backupDir, "metadata.json") + assert.FileExists(t, metaPath) + + // modify primary config to see if a second run overwrites it + err = os.WriteFile(primaryPath, []byte("Host modified\n"), 0600) + assert.NoError(t, err) + + err = mgr.EnsureFirstRunBackup() + assert.NoError(t, err) + + // original backed up file should still have the original content + backedUpPath := filepath.Join(backupDir, "config_0") + // #nosec G304 - testing with dynamic test file path + backupContent, err := os.ReadFile(backedUpPath) + assert.NoError(t, err) + assert.Equal(t, []byte("Host first\n"), backupContent) + }) + + t.Run("missing files are gracefully skipped", func(t *testing.T) { + tmpDir := t.TempDir() + primaryPath := filepath.Join(tmpDir, "config") + missingPath := filepath.Join(tmpDir, "missing") + + err := os.WriteFile(primaryPath, []byte("Host primary\n"), 0600) + assert.NoError(t, err) + + mgr := NewManager(primaryPath) + mgr.FileOrder = []string{primaryPath, missingPath} + + err = mgr.EnsureFirstRunBackup() + assert.NoError(t, err) + + backupDir := filepath.Join(tmpDir, "pre-tusshi") + metaPath := filepath.Join(backupDir, "metadata.json") + assert.FileExists(t, metaPath) + + // #nosec G304 - testing with dynamic test file path + metaBytes, err := os.ReadFile(metaPath) + assert.NoError(t, err) + + var meta BackupMetadata + err = json.Unmarshal(metaBytes, &meta) + assert.NoError(t, err) + + assert.Len(t, meta.Files, 1) + assert.Equal(t, primaryPath, meta.Files[0].OriginalPath) + }) +} diff --git a/pkg/tui/components/alert.go b/pkg/tui/components/alert.go new file mode 100644 index 0000000..e9031de --- /dev/null +++ b/pkg/tui/components/alert.go @@ -0,0 +1,76 @@ +package components + +import ( + "strings" + "tusshi/pkg/tui/theme" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// Alert represents a reusable, self-contained TUI dialog modal for notices or errors. +type Alert struct { + Title string + Message string + IsError bool + Theme theme.Theme +} + +// Init initializes the alert component. +func (a *Alert) Init() tea.Cmd { + return nil +} + +// Update processes navigation and dismiss events. +func (a *Alert) Update(msg tea.Msg) (tea.Cmd, bool) { + if keyMsg, ok := msg.(tea.KeyMsg); ok { + switch keyMsg.String() { + case keyEsc, "q", keyEnter: + return nil, true + } + } + return nil, false +} + +// View renders the alert box styled with Lip Gloss. +func (a *Alert) View(width int) string { + accentColor := a.Theme.Primary + if a.IsError { + accentColor = a.Theme.Error + } + + titleStyle := lipgloss.NewStyle(). + Foreground(accentColor). + Bold(true). + Align(lipgloss.Center). + Width(width) + + msgStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("255")). + Align(lipgloss.Center). + Width(width) + + divider := lipgloss.NewStyle().Foreground(a.Theme.Muted).Render(strings.Repeat("─", width)) + + okBtn := lipgloss.NewStyle(). + Background(accentColor). + Foreground(lipgloss.Color("0")). + Bold(true). + Padding(0, 3). + Render(" OK ") + + buttonsStyle := lipgloss.NewStyle().Align(lipgloss.Center).Width(width) + + rows := []string{ + titleStyle.Render(a.Title), + divider, + "", + msgStyle.Render(a.Message), + "", + buttonsStyle.Render(okBtn), + "", + lipgloss.NewStyle().Foreground(a.Theme.Muted).Align(lipgloss.Center).Width(width).Render("Press Enter or Esc to dismiss"), + } + + return strings.Join(rows, "\n") +} diff --git a/pkg/tui/components/alert_test.go b/pkg/tui/components/alert_test.go new file mode 100644 index 0000000..2cbc5f5 --- /dev/null +++ b/pkg/tui/components/alert_test.go @@ -0,0 +1,52 @@ +package components_test + +import ( + "strings" + "testing" + + "tusshi/pkg/tui/components" + "tusshi/pkg/tui/theme" + + tea "github.com/charmbracelet/bubbletea" +) + +func TestAlertComponent(t *testing.T) { + alert := &components.Alert{ + Title: "Backup Failed", + Message: "Could not create initial pre-tuSSHi backup", + IsError: true, + Theme: theme.Mock, + } + + if cmd := alert.Init(); cmd != nil { + t.Errorf("expected Init to return nil, got %v", cmd) + } + + rendered := alert.View(50) + if !strings.Contains(rendered, "Backup Failed") { + t.Error("expected view to contain title") + } + if !strings.Contains(rendered, "Could not create initial pre-tuSSHi backup") { + t.Error("expected view to contain message") + } + if !strings.Contains(rendered, "OK") { + t.Error("expected view to contain OK button") + } + + _, done := alert.Update(tea.KeyMsg{Type: tea.KeyEsc}) + if !done { + t.Error("expected Esc to dismiss alert") + } + _, done = alert.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("q")}) + if !done { + t.Error("expected 'q' to dismiss alert") + } + _, done = alert.Update(tea.KeyMsg{Type: tea.KeyEnter}) + if !done { + t.Error("expected Enter to dismiss alert") + } + _, done = alert.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x")}) + if done { + t.Error("expected other keys to not dismiss alert") + } +} diff --git a/pkg/tui/components/component.go b/pkg/tui/components/component.go index ea7b6da..def0d11 100644 --- a/pkg/tui/components/component.go +++ b/pkg/tui/components/component.go @@ -3,7 +3,10 @@ package components import tea "github.com/charmbracelet/bubbletea" -const keyEsc = "esc" +const ( + keyEsc = "esc" + keyEnter = "enter" +) // Component represents a self-contained interactive UI overlay. type Component interface { diff --git a/pkg/tui/components/confirm.go b/pkg/tui/components/confirm.go index de38976..d664e3e 100644 --- a/pkg/tui/components/confirm.go +++ b/pkg/tui/components/confirm.go @@ -47,7 +47,7 @@ func (c *Confirm) Update(msg tea.Msg) (tea.Cmd, bool) { c.YesSelected = true case "right", "l": c.YesSelected = false - case "enter": + case keyEnter: if c.YesSelected && c.OnConfirm != nil { return c.OnConfirm(), true } diff --git a/pkg/tui/components/help.go b/pkg/tui/components/help.go index ec4b845..aadb026 100644 --- a/pkg/tui/components/help.go +++ b/pkg/tui/components/help.go @@ -30,7 +30,7 @@ func (h *Help) Init() tea.Cmd { func (h *Help) Update(msg tea.Msg) (tea.Cmd, bool) { if keyMsg, ok := msg.(tea.KeyMsg); ok { switch keyMsg.String() { - case keyEsc, "q", "enter": + case keyEsc, "q", keyEnter: return nil, true } } diff --git a/pkg/tui/model.go b/pkg/tui/model.go index e1dce52..d576ee9 100644 --- a/pkg/tui/model.go +++ b/pkg/tui/model.go @@ -6,6 +6,7 @@ import ( "tusshi/pkg/config" "tusshi/pkg/tui/components" + "tusshi/pkg/tui/theme" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" @@ -88,6 +89,16 @@ func NewModel(mgr *config.Manager) *Model { PingResults: make(map[string]*PingResult), } m.Reload() + + if err := mgr.EnsureFirstRunBackup(); err != nil { + m.ActiveComponent = &components.Alert{ + Title: "Backup Failed", + Message: "Could not create initial pre-tuSSHi backup:\n" + err.Error() + "\n\nPlease ensure SSH directory permissions are correct.", + IsError: true, + Theme: theme.Global, + } + } + return m }