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