Skip to content

Commit 8cb7368

Browse files
authored
feat: add backup on first run (#3)
1 parent f71aa7b commit 8cb7368

8 files changed

Lines changed: 385 additions & 3 deletions

File tree

pkg/config/backup.go

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
package config
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"io"
7+
"os"
8+
"path/filepath"
9+
"time"
10+
)
11+
12+
// BackupFileMap holds path association of a backed up config file.
13+
type BackupFileMap struct {
14+
OriginalPath string `json:"original_path"`
15+
BackupName string `json:"backup_name"`
16+
}
17+
18+
// BackupMetadata preserves information about the original source configurations.
19+
type BackupMetadata struct {
20+
Timestamp string `json:"timestamp"`
21+
Files []BackupFileMap `json:"files"`
22+
}
23+
24+
// EnsureFirstRunBackup checks if a pre-tusshi backup already exists.
25+
// If not, it creates a pristine backup of all configuration files in m.FileOrder
26+
// inside a pre-tusshi directory next to the primary configuration.
27+
// It checks existence based on the metadata file.
28+
func (m *Manager) EnsureFirstRunBackup() error {
29+
backupDir := filepath.Join(filepath.Dir(m.PrimaryPath), "pre-tusshi")
30+
metaPath := filepath.Join(backupDir, "metadata.json")
31+
32+
if _, err := os.Stat(metaPath); err == nil {
33+
return nil // backup already exists
34+
}
35+
36+
var existingFiles []string
37+
for _, path := range m.FileOrder {
38+
if path == "" {
39+
continue
40+
}
41+
if _, err := os.Stat(path); err == nil {
42+
existingFiles = append(existingFiles, path)
43+
}
44+
}
45+
46+
// enforce secure, user-only read/write access to the backup folder
47+
if err := os.MkdirAll(backupDir, 0700); err != nil {
48+
return fmt.Errorf("failed to create backup directory: %w", err)
49+
}
50+
51+
meta := BackupMetadata{
52+
Timestamp: time.Now().Format(time.RFC3339),
53+
Files: make([]BackupFileMap, 0),
54+
}
55+
56+
for i, srcPath := range existingFiles {
57+
baseName := filepath.Base(srcPath)
58+
backupName := fmt.Sprintf("%s_%d", baseName, i)
59+
dstPath := filepath.Join(backupDir, backupName)
60+
61+
if err := backupFile(srcPath, dstPath); err != nil {
62+
return fmt.Errorf("failed to backup file %q: %w", srcPath, err)
63+
}
64+
65+
meta.Files = append(meta.Files, BackupFileMap{
66+
OriginalPath: srcPath,
67+
BackupName: backupName,
68+
})
69+
}
70+
71+
metaBytes, err := json.MarshalIndent(meta, "", " ")
72+
if err != nil {
73+
return fmt.Errorf("failed to marshal backup metadata: %w", err)
74+
}
75+
76+
// enforce secure user-only access on the metadata file
77+
if err := os.WriteFile(metaPath, metaBytes, 0600); err != nil {
78+
return fmt.Errorf("failed to write backup metadata: %w", err)
79+
}
80+
81+
return nil
82+
}
83+
84+
// backupFile copies the content of a file to the destination with 0600 permissions.
85+
func backupFile(src, dst string) error {
86+
srcFile, err := os.Open(filepath.Clean(src))
87+
if err != nil {
88+
return err
89+
}
90+
defer func() {
91+
_ = srcFile.Close()
92+
}()
93+
94+
dstFile, err := os.OpenFile(filepath.Clean(dst), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
95+
if err != nil {
96+
return err
97+
}
98+
defer func() {
99+
_ = dstFile.Close()
100+
}()
101+
102+
if _, err := io.Copy(dstFile, srcFile); err != nil {
103+
return err
104+
}
105+
106+
return dstFile.Sync()
107+
}

pkg/config/backup_test.go

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
package config
2+
3+
import (
4+
"encoding/json"
5+
"os"
6+
"path/filepath"
7+
"testing"
8+
9+
"github.com/stretchr/testify/assert"
10+
)
11+
12+
// TestEnsureFirstRunBackup tests the creation of a pre-tusshi backup on the first run.
13+
func TestEnsureFirstRunBackup(t *testing.T) {
14+
t.Run("successful backup of primary and includes", func(t *testing.T) {
15+
tmpDir := t.TempDir()
16+
primaryPath := filepath.Join(tmpDir, "config")
17+
includePath := filepath.Join(tmpDir, "include_config")
18+
19+
err := os.WriteFile(primaryPath, []byte("Host test\n HostName 1.2.3.4\n"), 0600)
20+
assert.NoError(t, err)
21+
22+
err = os.WriteFile(includePath, []byte("Host include\n HostName 5.6.7.8\n"), 0600)
23+
assert.NoError(t, err)
24+
25+
mgr := NewManager(primaryPath)
26+
mgr.FileOrder = []string{primaryPath, includePath}
27+
28+
err = mgr.EnsureFirstRunBackup()
29+
assert.NoError(t, err)
30+
31+
backupDir := filepath.Join(tmpDir, "pre-tusshi")
32+
assert.DirExists(t, backupDir)
33+
34+
// check folder permissions (must be 0700 on Unix)
35+
info, err := os.Stat(backupDir)
36+
assert.NoError(t, err)
37+
assert.Equal(t, os.ModeDir|0700, info.Mode().Perm()|os.ModeDir)
38+
39+
metaPath := filepath.Join(backupDir, "metadata.json")
40+
assert.FileExists(t, metaPath)
41+
42+
// #nosec G304 - metaPath is a dynamic test path inside a temporary directory
43+
metaBytes, err := os.ReadFile(metaPath)
44+
assert.NoError(t, err)
45+
46+
var meta BackupMetadata
47+
err = json.Unmarshal(metaBytes, &meta)
48+
assert.NoError(t, err)
49+
50+
assert.NotEmpty(t, meta.Timestamp)
51+
assert.Len(t, meta.Files, 2)
52+
53+
// verify metadata content and permissions of backed up files
54+
for _, f := range meta.Files {
55+
backedUpPath := filepath.Join(backupDir, f.BackupName)
56+
assert.FileExists(t, backedUpPath)
57+
58+
fileInfo, err := os.Stat(backedUpPath)
59+
assert.NoError(t, err)
60+
assert.Equal(t, os.FileMode(0600), fileInfo.Mode().Perm())
61+
62+
// #nosec G304 - testing with dynamic test file path
63+
originalContent, err := os.ReadFile(f.OriginalPath)
64+
assert.NoError(t, err)
65+
// #nosec G304 - testing with dynamic test file path
66+
backupContent, err := os.ReadFile(backedUpPath)
67+
assert.NoError(t, err)
68+
assert.Equal(t, originalContent, backupContent)
69+
}
70+
})
71+
72+
t.Run("consecutive run prevents redundant backup", func(t *testing.T) {
73+
tmpDir := t.TempDir()
74+
primaryPath := filepath.Join(tmpDir, "config")
75+
76+
err := os.WriteFile(primaryPath, []byte("Host first\n"), 0600)
77+
assert.NoError(t, err)
78+
79+
mgr := NewManager(primaryPath)
80+
mgr.FileOrder = []string{primaryPath}
81+
82+
err = mgr.EnsureFirstRunBackup()
83+
assert.NoError(t, err)
84+
85+
backupDir := filepath.Join(tmpDir, "pre-tusshi")
86+
metaPath := filepath.Join(backupDir, "metadata.json")
87+
assert.FileExists(t, metaPath)
88+
89+
// modify primary config to see if a second run overwrites it
90+
err = os.WriteFile(primaryPath, []byte("Host modified\n"), 0600)
91+
assert.NoError(t, err)
92+
93+
err = mgr.EnsureFirstRunBackup()
94+
assert.NoError(t, err)
95+
96+
// original backed up file should still have the original content
97+
backedUpPath := filepath.Join(backupDir, "config_0")
98+
// #nosec G304 - testing with dynamic test file path
99+
backupContent, err := os.ReadFile(backedUpPath)
100+
assert.NoError(t, err)
101+
assert.Equal(t, []byte("Host first\n"), backupContent)
102+
})
103+
104+
t.Run("missing files are gracefully skipped", func(t *testing.T) {
105+
tmpDir := t.TempDir()
106+
primaryPath := filepath.Join(tmpDir, "config")
107+
missingPath := filepath.Join(tmpDir, "missing")
108+
109+
err := os.WriteFile(primaryPath, []byte("Host primary\n"), 0600)
110+
assert.NoError(t, err)
111+
112+
mgr := NewManager(primaryPath)
113+
mgr.FileOrder = []string{primaryPath, missingPath}
114+
115+
err = mgr.EnsureFirstRunBackup()
116+
assert.NoError(t, err)
117+
118+
backupDir := filepath.Join(tmpDir, "pre-tusshi")
119+
metaPath := filepath.Join(backupDir, "metadata.json")
120+
assert.FileExists(t, metaPath)
121+
122+
// #nosec G304 - testing with dynamic test file path
123+
metaBytes, err := os.ReadFile(metaPath)
124+
assert.NoError(t, err)
125+
126+
var meta BackupMetadata
127+
err = json.Unmarshal(metaBytes, &meta)
128+
assert.NoError(t, err)
129+
130+
assert.Len(t, meta.Files, 1)
131+
assert.Equal(t, primaryPath, meta.Files[0].OriginalPath)
132+
})
133+
}

pkg/tui/components/alert.go

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package components
2+
3+
import (
4+
"strings"
5+
"tusshi/pkg/tui/theme"
6+
7+
tea "github.com/charmbracelet/bubbletea"
8+
"github.com/charmbracelet/lipgloss"
9+
)
10+
11+
// Alert represents a reusable, self-contained TUI dialog modal for notices or errors.
12+
type Alert struct {
13+
Title string
14+
Message string
15+
IsError bool
16+
Theme theme.Theme
17+
}
18+
19+
// Init initializes the alert component.
20+
func (a *Alert) Init() tea.Cmd {
21+
return nil
22+
}
23+
24+
// Update processes navigation and dismiss events.
25+
func (a *Alert) Update(msg tea.Msg) (tea.Cmd, bool) {
26+
if keyMsg, ok := msg.(tea.KeyMsg); ok {
27+
switch keyMsg.String() {
28+
case keyEsc, "q", keyEnter:
29+
return nil, true
30+
}
31+
}
32+
return nil, false
33+
}
34+
35+
// View renders the alert box styled with Lip Gloss.
36+
func (a *Alert) View(width int) string {
37+
accentColor := a.Theme.Primary
38+
if a.IsError {
39+
accentColor = a.Theme.Error
40+
}
41+
42+
titleStyle := lipgloss.NewStyle().
43+
Foreground(accentColor).
44+
Bold(true).
45+
Align(lipgloss.Center).
46+
Width(width)
47+
48+
msgStyle := lipgloss.NewStyle().
49+
Foreground(lipgloss.Color("255")).
50+
Align(lipgloss.Center).
51+
Width(width)
52+
53+
divider := lipgloss.NewStyle().Foreground(a.Theme.Muted).Render(strings.Repeat("─", width))
54+
55+
okBtn := lipgloss.NewStyle().
56+
Background(accentColor).
57+
Foreground(lipgloss.Color("0")).
58+
Bold(true).
59+
Padding(0, 3).
60+
Render(" OK ")
61+
62+
buttonsStyle := lipgloss.NewStyle().Align(lipgloss.Center).Width(width)
63+
64+
rows := []string{
65+
titleStyle.Render(a.Title),
66+
divider,
67+
"",
68+
msgStyle.Render(a.Message),
69+
"",
70+
buttonsStyle.Render(okBtn),
71+
"",
72+
lipgloss.NewStyle().Foreground(a.Theme.Muted).Align(lipgloss.Center).Width(width).Render("Press Enter or Esc to dismiss"),
73+
}
74+
75+
return strings.Join(rows, "\n")
76+
}

pkg/tui/components/alert_test.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package components_test
2+
3+
import (
4+
"strings"
5+
"testing"
6+
7+
"tusshi/pkg/tui/components"
8+
"tusshi/pkg/tui/theme"
9+
10+
tea "github.com/charmbracelet/bubbletea"
11+
)
12+
13+
func TestAlertComponent(t *testing.T) {
14+
alert := &components.Alert{
15+
Title: "Backup Failed",
16+
Message: "Could not create initial pre-tuSSHi backup",
17+
IsError: true,
18+
Theme: theme.Mock,
19+
}
20+
21+
if cmd := alert.Init(); cmd != nil {
22+
t.Errorf("expected Init to return nil, got %v", cmd)
23+
}
24+
25+
rendered := alert.View(50)
26+
if !strings.Contains(rendered, "Backup Failed") {
27+
t.Error("expected view to contain title")
28+
}
29+
if !strings.Contains(rendered, "Could not create initial pre-tuSSHi backup") {
30+
t.Error("expected view to contain message")
31+
}
32+
if !strings.Contains(rendered, "OK") {
33+
t.Error("expected view to contain OK button")
34+
}
35+
36+
_, done := alert.Update(tea.KeyMsg{Type: tea.KeyEsc})
37+
if !done {
38+
t.Error("expected Esc to dismiss alert")
39+
}
40+
_, done = alert.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("q")})
41+
if !done {
42+
t.Error("expected 'q' to dismiss alert")
43+
}
44+
_, done = alert.Update(tea.KeyMsg{Type: tea.KeyEnter})
45+
if !done {
46+
t.Error("expected Enter to dismiss alert")
47+
}
48+
_, done = alert.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x")})
49+
if done {
50+
t.Error("expected other keys to not dismiss alert")
51+
}
52+
}

pkg/tui/components/component.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@ package components
33

44
import tea "github.com/charmbracelet/bubbletea"
55

6-
const keyEsc = "esc"
6+
const (
7+
keyEsc = "esc"
8+
keyEnter = "enter"
9+
)
710

811
// Component represents a self-contained interactive UI overlay.
912
type Component interface {

pkg/tui/components/confirm.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ func (c *Confirm) Update(msg tea.Msg) (tea.Cmd, bool) {
4747
c.YesSelected = true
4848
case "right", "l":
4949
c.YesSelected = false
50-
case "enter":
50+
case keyEnter:
5151
if c.YesSelected && c.OnConfirm != nil {
5252
return c.OnConfirm(), true
5353
}

0 commit comments

Comments
 (0)