返回 DeepSeek-Reasonix
updater_windows.go
根目录 / desktop / updater_windows.go
1 //go:build windows
2
3 package main
4
5 import (
6 "bytes"
7 "crypto/sha256"
8 "debug/pe"
9 "encoding/hex"
10 "errors"
11 "fmt"
12 "io"
13 "os"
14 "os/exec"
15 "path/filepath"
16 "runtime"
17 "strings"
18 "sync"
19 "syscall"
20 "time"
21
22 "golang.org/x/sys/windows"
23
24 "reasonix/desktop/internal/instanceidentity"
25 "reasonix/internal/config"
26 "reasonix/internal/installlayout"
27 "reasonix/internal/proc"
28 "reasonix/internal/repair"
29 )
30
31 // resolveWindowsUpdateHelperSource finds the on-disk helper for the running
32 // install: versioned active dir first, then the flat InstallRoot layout.
33 func resolveWindowsUpdateHelperSource(installDir string) string {
34 if path, err := installlayout.ActiveUpdateHelperPath(installDir); err == nil {
35 return path
36 }
37 return filepath.Join(installDir, windowsUpdateHelperFileName)
38 }
39
40 const windowsUpdateHelperFileName = "reasonix-update-helper.exe"
41
42 var claimWindowsUpdateHelperExecutionFn = claimVerifiedWindowsUpdateHelperExecution
43
44 // installerCommand runs the NSIS updater in its visible, progress-only staging
45 // mode, forcing $INSTDIR to dir via /D= so the signed payload is extracted away
46 // from the live install. NSIS requires /D= to be the final, unquoted token taken
47 // verbatim to the end of the line, so the raw command line is set directly —
48 // exec.Command would quote a path containing spaces (e.g. C:\Users\Jane Doe\...)
49 // and NSIS would then mis-parse the target directory.
50 func installerCommand(name, dir string) *exec.Cmd {
51 cmd := proc.VisibleCommand(name)
52 cmd.SysProcAttr = &syscall.SysProcAttr{CmdLine: installerCommandLine(name, dir)}
53 return cmd
54 }
55
56 func startWindowsUpdateHandoff(installerPath, installerSHA256, installDir, relaunchPath string, prepared *repair.UpdateTransaction) error {
57 // The helper is the only process that can observe an installer failure after
58 // the desktop exits and route recovery back through Guard. Starting NSIS
59 // directly here would make a failed/partial install indistinguishable from a
60 // successful handoff, so a missing or quarantined helper must fail safely.
61 return startWindowsUpdateHelper(installerPath, installerSHA256, installDir, relaunchPath, prepared)
62 }
63
64 func startWindowsVersionedUpdateHandoff(installerPath, installerSHA256, installDir, relaunchPath, targetVersion string) error {
65 if installDir == "" {
66 return os.ErrNotExist
67 }
68 helperPath, helperSHA256, err := prepareVersionedWindowsUpdateHelper(installDir)
69 if err != nil {
70 return err
71 }
72 releaseExecution, err := claimWindowsUpdateHelperExecutionFn(helperPath, helperSHA256)
73 if err != nil {
74 return fmt.Errorf("claim copied Windows update helper: %w", err)
75 }
76 defer releaseExecution()
77 err = retryWindowsUpdateHelperStart(func() error {
78 cmd := proc.Command(helperPath, windowsVersionedUpdateHandoffArgs(
79 windowsUpdateOwnerPID(), installerPath, installerSHA256, installDir, relaunchPath, targetVersion,
80 )...)
81 cmd.Env = instanceidentity.UpdateEnvironment(os.Environ(), config.ReasonixHomeDir())
82 return cmd.Start()
83 })
84 if err != nil {
85 return windowsUpdateHelperStartError(err)
86 }
87 return nil
88 }
89
90 func startWindowsUpdateHelper(installerPath, installerSHA256, installDir, relaunchPath string, prepared *repair.UpdateTransaction) error {
91 if installDir == "" {
92 return os.ErrNotExist
93 }
94 preparedHelperSHA256, err := preparedWindowsUpdateHelperSHA256(prepared, installDir)
95 if err != nil {
96 return err
97 }
98 helperPath, helperSHA256, err := prepareWindowsUpdateHelper(installDir, preparedHelperSHA256)
99 if err != nil {
100 return err
101 }
102 releaseExecution, err := claimWindowsUpdateHelperExecutionFn(helperPath, helperSHA256)
103 if err != nil {
104 return fmt.Errorf("claim copied Windows update helper: %w", err)
105 }
106 defer releaseExecution()
107 err = retryWindowsUpdateHelperStart(func() error {
108 cmd := proc.Command(helperPath, windowsUpdateHandoffArgs(
109 windowsUpdateOwnerPID(),
110 installerPath,
111 installerSHA256,
112 installDir,
113 relaunchPath,
114 prepared.ToVersion,
115 prepared.CreatedAt,
116 repair.UpdateTransactionID(prepared),
117 )...)
118 cmd.Env = instanceidentity.UpdateEnvironment(os.Environ(), config.ReasonixHomeDir())
119 return cmd.Start()
120 })
121 if err != nil {
122 return windowsUpdateHelperStartError(err)
123 }
124 return nil
125 }
126
127 func preparedWindowsUpdateHelperSHA256(prepared *repair.UpdateTransaction, installDir string) (string, error) {
128 if prepared == nil || prepared.TargetKind != "file" || repair.UpdateTransactionID(prepared) == "" {
129 return "", fmt.Errorf("prepare Windows update helper: transaction identity is incomplete")
130 }
131 // Match by basename so versioned layouts (versions/<ver>/helper) and flat
132 // layouts (InstallRoot/helper) share one prepare path.
133 for _, file := range prepared.Files {
134 if !strings.EqualFold(filepath.Base(file.TargetPath), windowsUpdateHelperFileName) {
135 continue
136 }
137 if file.MissingBefore || !validWindowsSHA256(file.SHA256) {
138 return "", fmt.Errorf("prepare Windows update helper: prepared helper identity is incomplete")
139 }
140 return strings.TrimSpace(file.SHA256), nil
141 }
142 _ = installDir
143 return "", fmt.Errorf("prepare Windows update helper: helper is outside the prepared release unit")
144 }
145
146 func validWindowsSHA256(value string) bool {
147 value = strings.TrimSpace(value)
148 if len(value) != sha256.Size*2 {
149 return false
150 }
151 _, err := hex.DecodeString(value)
152 return err == nil
153 }
154
155 func prepareWindowsUpdateHelper(installDir, preparedSHA256 string) (string, [sha256.Size]byte, error) {
156 src := resolveWindowsUpdateHelperSource(installDir)
157 data, err := os.ReadFile(src)
158 if err != nil {
159 return "", [sha256.Size]byte{}, err
160 }
161 expectedSHA256 := sha256.Sum256(data)
162 if !strings.EqualFold(hex.EncodeToString(expectedSHA256[:]), strings.TrimSpace(preparedSHA256)) {
163 return "", [sha256.Size]byte{}, fmt.Errorf("packaged Windows update helper changed after transaction prepare")
164 }
165 if err := validateWindowsUpdateHelper(data, runtime.GOARCH); err != nil {
166 return "", [sha256.Size]byte{}, fmt.Errorf("validate packaged Windows update helper: %w", err)
167 }
168 dir, err := updateCacheDir()
169 if err != nil {
170 return "", [sha256.Size]byte{}, err
171 }
172 dst, err := stageWindowsUpdateHelperCopy(dir, data)
173 if err != nil {
174 return "", [sha256.Size]byte{}, err
175 }
176 return dst, expectedSHA256, nil
177 }
178
179 func prepareVersionedWindowsUpdateHelper(installDir string) (string, [sha256.Size]byte, error) {
180 src := resolveWindowsUpdateHelperSource(installDir)
181 data, err := os.ReadFile(src)
182 if err != nil {
183 return "", [sha256.Size]byte{}, err
184 }
185 expectedSHA256 := sha256.Sum256(data)
186 if err := validateWindowsUpdateHelper(data, runtime.GOARCH); err != nil {
187 return "", [sha256.Size]byte{}, fmt.Errorf("validate packaged Windows update helper: %w", err)
188 }
189 dir, err := updateCacheDir()
190 if err != nil {
191 return "", [sha256.Size]byte{}, err
192 }
193 dst, err := stageWindowsUpdateHelperCopy(dir, data)
194 if err != nil {
195 return "", [sha256.Size]byte{}, err
196 }
197 return dst, expectedSHA256, nil
198 }
199
200 func stageWindowsUpdateHelperCopy(dir string, data []byte) (string, error) {
201 staged, err := os.CreateTemp(dir, "reasonix-update-helper-*.exe")
202 if err != nil {
203 return "", err
204 }
205 dst := staged.Name()
206 fail := func(err error) (string, error) {
207 _ = staged.Close()
208 // Preserve the exclusively-created node on failure. A path-based remove
209 // after close could delete an unrelated replacement.
210 return "", err
211 }
212 if _, err := staged.Write(data); err != nil {
213 return fail(err)
214 }
215 if err := staged.Sync(); err != nil {
216 return fail(err)
217 }
218 if err := staged.Chmod(0o700); err != nil {
219 return fail(err)
220 }
221 if err := staged.Close(); err != nil {
222 return "", err
223 }
224 return dst, nil
225 }
226
227 func claimVerifiedWindowsUpdateHelperExecution(path string, expectedSHA256 [sha256.Size]byte) (func(), error) {
228 pathUTF16, err := windows.UTF16PtrFromString(path)
229 if err != nil {
230 return nil, err
231 }
232 handle, err := windows.CreateFile(
233 pathUTF16,
234 windows.GENERIC_READ,
235 windows.FILE_SHARE_READ,
236 nil,
237 windows.OPEN_EXISTING,
238 windows.FILE_ATTRIBUTE_NORMAL|windows.FILE_FLAG_SEQUENTIAL_SCAN,
239 0,
240 )
241 if err != nil {
242 return nil, err
243 }
244 file := os.NewFile(uintptr(handle), path)
245 fail := func(err error) (func(), error) {
246 _ = file.Close()
247 return nil, err
248 }
249 info, err := file.Stat()
250 if err != nil {
251 return fail(err)
252 }
253 if !info.Mode().IsRegular() {
254 return fail(fmt.Errorf("copied Windows update helper is not a regular file"))
255 }
256 hash := sha256.New()
257 if _, err := io.Copy(hash, file); err != nil {
258 return fail(err)
259 }
260 if !bytes.Equal(hash.Sum(nil), expectedSHA256[:]) {
261 return fail(fmt.Errorf("copied Windows update helper changed before execution"))
262 }
263 var once sync.Once
264 return func() {
265 once.Do(func() { _ = file.Close() })
266 }, nil
267 }
268
269 func validateWindowsUpdateHelper(data []byte, goarch string) error {
270 f, err := pe.NewFile(bytes.NewReader(data))
271 if err != nil {
272 return fmt.Errorf("invalid PE image: %w", err)
273 }
274 defer f.Close()
275 want, ok := windowsPEMachine(goarch)
276 if !ok {
277 return fmt.Errorf("unsupported Windows architecture %q", goarch)
278 }
279 if f.Machine != want {
280 return fmt.Errorf("PE machine 0x%x does not match %s", f.Machine, goarch)
281 }
282 return nil
283 }
284
285 func windowsPEMachine(goarch string) (uint16, bool) {
286 switch goarch {
287 case "amd64":
288 return pe.IMAGE_FILE_MACHINE_AMD64, true
289 case "arm64":
290 return pe.IMAGE_FILE_MACHINE_ARM64, true
291 case "386":
292 return pe.IMAGE_FILE_MACHINE_I386, true
293 default:
294 return 0, false
295 }
296 }
297
298 const windowsHelperStartAttempts = 3
299
300 var windowsHelperStartBackoff = func(attempt int) time.Duration {
301 return time.Duration(attempt) * 250 * time.Millisecond
302 }
303
304 func retryWindowsUpdateHelperStart(start func() error) error {
305 var err error
306 for attempt := 1; attempt <= windowsHelperStartAttempts; attempt++ {
307 if err = start(); err == nil {
308 return nil
309 }
310 if !isRetryableWindowsHelperStartError(err) || attempt == windowsHelperStartAttempts {
311 break
312 }
313 time.Sleep(windowsHelperStartBackoff(attempt))
314 }
315 return err
316 }
317
318 func isRetryableWindowsHelperStartError(err error) bool {
319 return errors.Is(err, windows.ERROR_ACCESS_DENIED) ||
320 errors.Is(err, windows.ERROR_SHARING_VIOLATION) ||
321 errors.Is(err, windows.ERROR_LOCK_VIOLATION)
322 }
323
324 func windowsUpdateHelperStartError(err error) error {
325 switch {
326 case errors.Is(err, windows.ERROR_FILE_NOT_FOUND), errors.Is(err, windows.ERROR_PATH_NOT_FOUND):
327 return fmt.Errorf("start Windows update helper: the helper disappeared after verification; security software may have quarantined it")
328 case errors.Is(err, windows.ERROR_BAD_EXE_FORMAT):
329 return fmt.Errorf("start Windows update helper: Windows rejected the helper as corrupt or incompatible")
330 case errors.Is(err, windows.ERROR_ELEVATION_REQUIRED):
331 return fmt.Errorf("start Windows update helper: Windows unexpectedly requested administrator elevation")
332 case errors.Is(err, windows.ERROR_ACCESS_DENIED):
333 return fmt.Errorf("start Windows update helper: Windows or security software denied process creation")
334 }
335 var errno syscall.Errno
336 if errors.As(err, &errno) {
337 return fmt.Errorf("start Windows update helper: Windows error %w (%s)", errno, errno.Error())
338 }
339 return fmt.Errorf("start Windows update helper: process creation failed")
340 }
341
341 lines GO