| 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 |