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