1package updater
2
3import (
4 "context"
5 "fmt"
6 "os"
7 "path/filepath"
8
9 "golang.org/x/sys/unix"
10
11 "github.com/bnema/dumber/internal/infrastructure/config"
12 "github.com/bnema/dumber/internal/logging"
13)
14
15const (
16 // Staging directory name within XDG_STATE_HOME.
17 stagingDirName = "pending-update"
18 // Staged binary filename.
19 stagedBinaryName = "dumber"
20 // Backup suffix for old binary.
21 backupSuffix = ".old"
22 // File permission for directories and executables.
23 applierExecPerm = 0o755
24 // Execute permission bit for owner.
25 execBit = 0o100
26)
27
28// Applier implements UpdateApplier for self-updating the binary.
29type Applier struct {
30 stateDir string
31}
32
33// NewApplier creates a new update applier.
34// stateDir is the XDG state directory (e.g., ~/.local/state/dumber).
35func NewApplier(stateDir string) *Applier {
36 return &Applier{
37 stateDir: stateDir,
38 }
39}
40
41// NewApplierFromXDG creates a new update applier using XDG directories.
42func NewApplierFromXDG() (*Applier, error) {
43 stateDir, err := config.GetStateDir()
44 if err != nil {
45 return nil, fmt.Errorf("failed to get state directory: %w", err)
46 }
47 return NewApplier(stateDir), nil
48}
49
50// stagingDir returns the path to the staging directory.
51func (a *Applier) stagingDir() string {
52 return filepath.Join(a.stateDir, stagingDirName)
53}
54
55// stagedBinaryPath returns the path to the staged binary.
56func (a *Applier) stagedBinaryPath() string {
57 return filepath.Join(a.stagingDir(), stagedBinaryName)
58}
59
60// CanSelfUpdate checks if the current binary is writable by the current user.
61func (a *Applier) CanSelfUpdate(ctx context.Context) bool {
62 log := logging.FromContext(ctx)
63
64 binaryPath, err := a.GetBinaryPath()
65 if err != nil {
66 log.Debug().Err(err).Msg("failed to get binary path")
67 return false
68 }
69
70 // Check if we have write permission on the binary file.
71 err = unix.Access(binaryPath, unix.W_OK)
72 if err != nil {
73 log.Debug().
74 Str("path", binaryPath).
75 Err(err).
76 Msg("binary is not writable, self-update disabled")
77 return false
78 }
79
80 log.Debug().Str("path", binaryPath).Msg("binary is writable, self-update enabled")
81 return true
82}
83
84// GetBinaryPath returns the path to the currently running binary.
85func (*Applier) GetBinaryPath() (string, error) {
86 path, err := os.Executable()
87 if err != nil {
88 return "", fmt.Errorf("failed to get executable path: %w", err)
89 }
90
91 // Resolve any symlinks to get the actual binary path.
92 resolved, err := filepath.EvalSymlinks(path)
93 if err != nil {
94 return "", fmt.Errorf("failed to resolve symlinks: %w", err)
95 }
96
97 return resolved, nil
98}
99
100// StageUpdate copies the new binary to the staging directory.
101func (a *Applier) StageUpdate(ctx context.Context, newBinaryPath string) error {
102 log := logging.FromContext(ctx)
103
104 // Create staging directory.
105 stagingDir := a.stagingDir()
106 if err := os.MkdirAll(stagingDir, applierExecPerm); err != nil {
107 return fmt.Errorf("failed to create staging directory: %w", err)
108 }
109
110 // Read the new binary.
111 newBinary, err := os.ReadFile(newBinaryPath)
112 if err != nil {
113 return fmt.Errorf("failed to read new binary: %w", err)
114 }
115
116 // Write to staging location.
117 stagedPath := a.stagedBinaryPath()
118 if err := os.WriteFile(stagedPath, newBinary, applierExecPerm); err != nil {
119 return fmt.Errorf("failed to write staged binary: %w", err)
120 }
121
122 log.Info().
123 Str("staged_path", stagedPath).
124 Int("size", len(newBinary)).
125 Msg("update staged for exit")
126
127 return nil
128}
129
130// HasStagedUpdate checks if there is an update staged and ready to apply.
131func (a *Applier) HasStagedUpdate(_ context.Context) bool {
132 stagedPath := a.stagedBinaryPath()
133 info, err := os.Stat(stagedPath)
134 if err != nil {
135 return false
136 }
137 // Ensure it's a regular file with execute permission.
138 return info.Mode().IsRegular() && info.Mode()&execBit != 0
139}
140
141// ApplyOnExit replaces the current binary with the staged update.
142// This should be called during graceful shutdown.
143func (a *Applier) ApplyOnExit(ctx context.Context) (string, error) {
144 log := logging.FromContext(ctx)
145
146 if !a.HasStagedUpdate(ctx) {
147 return "", fmt.Errorf("no staged update found")
148 }
149
150 binaryPath, err := a.GetBinaryPath()
151 if err != nil {
152 return "", err
153 }
154
155 stagedPath := a.stagedBinaryPath()
156 backupPath := binaryPath + backupSuffix
157
158 log.Info().
159 Str("current", binaryPath).
160 Str("staged", stagedPath).
161 Str("backup", backupPath).
162 Msg("applying staged update")
163
164 // Step 1: Remove old backup if exists.
165 _ = os.Remove(backupPath)
166
167 // Step 2: Rename current binary to backup.
168 if err := os.Rename(binaryPath, backupPath); err != nil {
169 return "", fmt.Errorf("failed to backup current binary: %w", err)
170 }
171
172 // Step 3: Rename staged binary to current.
173 if err := os.Rename(stagedPath, binaryPath); err != nil {
174 // Try to restore backup.
175 if restoreErr := os.Rename(backupPath, binaryPath); restoreErr != nil {
176 log.Error().Err(restoreErr).Msg("failed to restore backup after update failure")
177 }
178 return "", fmt.Errorf("failed to install new binary: %w", err)
179 }
180
181 // Step 4: Clean up staging directory.
182 _ = os.RemoveAll(a.stagingDir())
183
184 log.Info().
185 Str("binary", binaryPath).
186 Str("backup", backupPath).
187 Msg("update applied successfully")
188
189 return backupPath, nil
190}
191
192// ClearStagedUpdate removes any staged update without applying it.
193func (a *Applier) ClearStagedUpdate(ctx context.Context) error {
194 log := logging.FromContext(ctx)
195
196 stagingDir := a.stagingDir()
197 if err := os.RemoveAll(stagingDir); err != nil {
198 return fmt.Errorf("failed to clear staged update: %w", err)
199 }
200
201 log.Debug().Str("path", stagingDir).Msg("cleared staged update")
202 return nil
203}