Skip to content
Merged
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
107 changes: 107 additions & 0 deletions pkg/config/backup.go
Original file line number Diff line number Diff line change
@@ -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()
}
133 changes: 133 additions & 0 deletions pkg/config/backup_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
76 changes: 76 additions & 0 deletions pkg/tui/components/alert.go
Original file line number Diff line number Diff line change
@@ -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")
}
52 changes: 52 additions & 0 deletions pkg/tui/components/alert_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
5 changes: 4 additions & 1 deletion pkg/tui/components/component.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion pkg/tui/components/confirm.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Loading
Loading