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