返回 DeepSeek-Reasonix
main.go
1 // Command reasonix-legacy-migrator is the one-shot flat→versioned install
2 // migrator used by v1.20+ packaging. In compatibility payloads it is still
3 // named reasonix-guard(.exe) so 1.18–1.19.1 updaters can hand off; the source
4 // and behavior are intentionally separate from the old Guard recovery product.
5 //
6 // It only: acquires a migration lock, validates the flat release unit, creates
7 // versions/<version>, writes current.json, rewrites entry points, starts the
8 // thin launcher, and self-deletes when possible. It never chooses safe mode,
9 // counts crashes, or auto-rolls back.
10 package main
11
12 import (
13 "encoding/json"
14 "fmt"
15 "os"
16 "os/exec"
17 "path/filepath"
18 "runtime"
19 "strings"
20 "time"
21
22 "reasonix/internal/desktoplauncher"
23 "reasonix/internal/fileutil"
24 "reasonix/internal/installlayout"
25 "reasonix/internal/repair"
26 )
27
28 var version = "dev"
29
30 const migrationLockName = ".reasonix-layout-migrate.lock"
31
32 func main() {
33 if runningAsLauncher() {
34 os.Exit(desktoplauncher.Run(os.Args[1:], version))
35 }
36 os.Exit(run(os.Args[1:]))
37 }
38
39 func run(args []string) int {
40 if len(args) == 1 {
41 switch args[0] {
42 case "version", "--version", "-v":
43 fmt.Println("reasonix-legacy-migrator", version)
44 return 0
45 case "help", "--help", "-h":
46 fmt.Println("usage: reasonix-legacy-migrator [--install-root PATH] [--version VERSION] [--activate-staging PATH]")
47 return 0
48 }
49 }
50
51 installRoot := ""
52 activeVersion := strings.TrimSpace(version)
53 activateStaging := ""
54 relaunch := true
55 for i := 0; i < len(args); i++ {
56 switch args[i] {
57 case "--install-root":
58 if i+1 >= len(args) {
59 fmt.Fprintln(os.Stderr, "error: --install-root requires a path")
60 return 2
61 }
62 i++
63 installRoot = args[i]
64 case "--version":
65 if i+1 >= len(args) {
66 fmt.Fprintln(os.Stderr, "error: --version requires a value")
67 return 2
68 }
69 i++
70 activeVersion = args[i]
71 case "--activate-staging":
72 if i+1 >= len(args) {
73 fmt.Fprintln(os.Stderr, "error: --activate-staging requires a path")
74 return 2
75 }
76 i++
77 activateStaging = args[i]
78 case "launch", "--detach", "--safe-mode":
79 // Accept legacy argv from old shortcuts; no product behavior.
80 continue
81 case "--no-relaunch":
82 relaunch = false
83 continue
84 case "--app":
85 if i+1 < len(args) {
86 i++
87 }
88 continue
89 }
90 }
91 if installRoot == "" {
92 exe, err := os.Executable()
93 if err != nil {
94 fmt.Fprintln(os.Stderr, "error:", err)
95 return 1
96 }
97 installRoot = filepath.Dir(exe)
98 }
99 installRoot = filepath.Clean(installRoot)
100 if activateStaging != "" {
101 activeVersion, err := normalizeActiveVersion(activeVersion)
102 if err != nil {
103 fmt.Fprintln(os.Stderr, "error:", err)
104 return 1
105 }
106 if err := activateInstallerStaging(installRoot, activeVersion, activateStaging); err != nil {
107 fmt.Fprintln(os.Stderr, "error:", err)
108 return 1
109 }
110 if relaunch {
111 _ = startLauncher(installRoot)
112 }
113 return 0
114 }
115
116 if err := migrateWithRelaunch(installRoot, activeVersion, relaunch); err != nil {
117 fmt.Fprintln(os.Stderr, "error:", err)
118 return 1
119 }
120 return 0
121 }
122
123 func migrate(installRoot, activeVersion string) error {
124 return migrateWithRelaunch(installRoot, activeVersion, true)
125 }
126
127 func migrateWithRelaunch(installRoot, activeVersion string, relaunch bool) error {
128 var err error
129 activeVersion, err = normalizeActiveVersion(activeVersion)
130 if err != nil {
131 return err
132 }
133
134 unlock, err := acquireMigrationLock(installRoot)
135 if err != nil {
136 return err
137 }
138 defer unlock()
139
140 // Pointer already committed: only cleanup, never overwrite the active version.
141 // A present but corrupt pointer is fatal; treating it like an absent pointer
142 // could overwrite evidence of a partial activation and migrate stale files.
143 currentPath := filepath.Join(installRoot, installlayout.CurrentFileName)
144 if _, statErr := os.Lstat(currentPath); statErr == nil {
145 ptr, err := installlayout.ReadCurrent(installRoot)
146 if err != nil {
147 return err
148 }
149 if err := finalizeLegacyPendingUpdate(installRoot, ptr.ActiveVersion); err != nil {
150 return err
151 }
152 _ = cleanupLegacyFlatFiles(installRoot)
153 _ = archiveInstallRootLegacyMarkers(installRoot)
154 if relaunch {
155 _ = startLauncher(installRoot) // best-effort; layout already committed
156 selfDelete()
157 }
158 return nil
159 } else if !os.IsNotExist(statErr) {
160 return fmt.Errorf("migrate: inspect current.json: %w", statErr)
161 }
162
163 desktopName := installlayout.DesktopBinaryName()
164 cliName := installlayout.CLIBinaryName()
165 helperName := installlayout.UpdateHelperBinaryName()
166 flatDesktop := filepath.Join(installRoot, desktopName)
167 if _, err := os.Lstat(flatDesktop); err != nil {
168 return fmt.Errorf("migrate: flat desktop binary missing: %w", err)
169 }
170
171 members := []installlayout.Member{
172 {Name: desktopName, Path: flatDesktop},
173 }
174 if p := optionalRegular(filepath.Join(installRoot, cliName)); p != "" {
175 members = append(members, installlayout.Member{Name: cliName, Path: p})
176 } else {
177 // CLI may be absent on some portable trees; synthesize from desktop only
178 // is not allowed — require the whitelist. Prefer copying desktop as a
179 // last-resort placeholder is forbidden; fail closed.
180 return fmt.Errorf("migrate: flat CLI binary %s is required", cliName)
181 }
182 requiredNames := []string{desktopName, cliName}
183 if runtime.GOOS == "windows" {
184 requiredNames = append(requiredNames, helperName)
185 if p := optionalRegular(filepath.Join(installRoot, helperName)); p != "" {
186 members = append(members, installlayout.Member{Name: helperName, Path: p})
187 } else {
188 return fmt.Errorf("migrate: flat update helper %s is required", helperName)
189 }
190 }
191
192 // Old Linux updaters only publish desktop, CLI, and the compatibility
193 // migrator. On Unix the migrator is also a thin-launcher multicall binary,
194 // so it can create the permanent entry point without another payload member.
195 if err := ensureLauncherEntry(installRoot); err != nil {
196 return err
197 }
198 // v1.18-v1.19 helpers leave an installed transaction awaiting Guard health.
199 // The flat release unit is still intact here, so commit that exact verified
200 // transaction before moving its files into the version directory.
201 if err := finalizeLegacyPendingUpdate(installRoot, activeVersion); err != nil {
202 return err
203 }
204
205 if err := installlayout.ActivateVersion(installlayout.ActivationRequest{
206 InstallRoot: installRoot,
207 Version: activeVersion,
208 RequestID: "legacy-migrate",
209 Members: members,
210 RequiredNames: requiredNames,
211 }); err != nil {
212 return fmt.Errorf("migrate: activate versioned layout: %w", err)
213 }
214
215 _ = cleanupLegacyFlatFiles(installRoot)
216 _ = archiveInstallRootLegacyMarkers(installRoot)
217 if relaunch {
218 // Launcher start is best-effort: layout activation is the commit point.
219 if err := startLauncher(installRoot); err != nil {
220 fmt.Fprintln(os.Stderr, "warning: start launcher after migrate:", err)
221 }
222 // Unix can unlink the running migrator. On Windows the parent thin launcher
223 // removes it after this process exits.
224 selfDelete()
225 }
226 return nil
227 }
228
229 func normalizeActiveVersion(activeVersion string) (string, error) {
230 if err := installlayout.ValidateVersionName(activeVersion); err != nil {
231 // Accept bare "dev" builds in development only.
232 if activeVersion == "" || activeVersion == "dev" {
233 activeVersion = "v0.0.0-dev"
234 if err := installlayout.ValidateVersionName(activeVersion); err != nil {
235 return "", err
236 }
237 } else if !strings.HasPrefix(activeVersion, "v") {
238 activeVersion = "v" + activeVersion
239 if err := installlayout.ValidateVersionName(activeVersion); err != nil {
240 return "", err
241 }
242 } else {
243 return "", err
244 }
245 }
246 return activeVersion, nil
247 }
248
249 func activateInstallerStaging(installRoot, activeVersion, stagingRoot string) error {
250 installRoot = filepath.Clean(strings.TrimSpace(installRoot))
251 stagingRoot = filepath.Clean(strings.TrimSpace(stagingRoot))
252 if !pathWithinInstallRoot(installRoot, stagingRoot) || stagingRoot == installRoot {
253 return fmt.Errorf("installer staging must be inside the install root")
254 }
255 info, err := os.Lstat(stagingRoot)
256 if err != nil {
257 return fmt.Errorf("inspect installer staging: %w", err)
258 }
259 if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
260 return fmt.Errorf("installer staging must be a real directory")
261 }
262
263 desktopName := installlayout.DesktopBinaryName()
264 cliName := installlayout.CLIBinaryName()
265 requiredNames := []string{desktopName, cliName}
266 if runtime.GOOS == "windows" {
267 requiredNames = append(requiredNames, installlayout.UpdateHelperBinaryName())
268 }
269 members := make([]installlayout.Member, 0, len(requiredNames))
270 for _, name := range requiredNames {
271 members = append(members, installlayout.Member{Name: name, Path: filepath.Join(stagingRoot, name)})
272 }
273
274 launcherName := installlayout.LauncherBinaryName()
275 launcherSource := filepath.Join(stagingRoot, launcherName)
276 rootMembers := []installlayout.Member{
277 {Name: launcherName, Path: launcherSource},
278 {Name: cliName, Path: filepath.Join(stagingRoot, cliName)},
279 }
280 requiredRootNames := []string{launcherName, cliName}
281 if alias := installlayout.PortableAliasName(); alias != "" {
282 rootMembers = append(rootMembers, installlayout.Member{Name: alias, Path: launcherSource})
283 requiredRootNames = append(requiredRootNames, alias)
284 }
285
286 if err := installlayout.ActivateVersion(installlayout.ActivationRequest{
287 InstallRoot: installRoot,
288 Version: activeVersion,
289 RequestID: "signed-installer-" + activeVersion,
290 Members: members,
291 RequiredNames: requiredNames,
292 RootMembers: rootMembers,
293 RequiredRootNames: requiredRootNames,
294 }); err != nil {
295 return fmt.Errorf("activate signed installer staging: %w", err)
296 }
297 return nil
298 }
299
300 func runningAsLauncher() bool {
301 exe, err := os.Executable()
302 if err != nil {
303 return false
304 }
305 return strings.EqualFold(filepath.Base(exe), installlayout.LauncherBinaryName())
306 }
307
308 func optionalRegular(path string) string {
309 info, err := os.Lstat(path)
310 if err != nil || !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
311 return ""
312 }
313 return path
314 }
315
316 func acquireMigrationLock(installRoot string) (func(), error) {
317 if err := os.MkdirAll(installRoot, 0o755); err != nil {
318 return nil, err
319 }
320 path := filepath.Join(installRoot, migrationLockName)
321 f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
322 if err != nil {
323 if os.IsExist(err) {
324 // Stale lock from a dead migrator: if older than 10 minutes, steal it.
325 info, statErr := os.Stat(path)
326 if statErr == nil && time.Since(info.ModTime()) > 10*time.Minute {
327 _ = os.Remove(path)
328 f, err = os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
329 }
330 }
331 if err != nil {
332 return nil, fmt.Errorf("migrate: acquire lock: %w", err)
333 }
334 }
335 _, _ = fmt.Fprintf(f, "pid=%d\nversion=%s\n", os.Getpid(), version)
336 _ = f.Close()
337 return func() { _ = os.Remove(path) }, nil
338 }
339
340 func ensureLauncherEntry(installRoot string) error {
341 launcher := installlayout.LauncherBinaryName()
342 path := filepath.Join(installRoot, launcher)
343 if info, err := os.Lstat(path); err == nil {
344 if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
345 return fmt.Errorf("migrate: thin launcher %s is not a regular file", launcher)
346 }
347 return nil
348 }
349 // On Windows also accept Reasonix.exe as the alias.
350 if runtime.GOOS == "windows" {
351 if _, err := os.Lstat(filepath.Join(installRoot, "Reasonix.exe")); err == nil {
352 return nil
353 }
354 return fmt.Errorf("migrate: thin launcher %s is missing; install a signed package", launcher)
355 }
356
357 // Legacy Linux archives cannot deliver a fourth member through the old
358 // updater. Copy this signed multicall migrator as the permanent thin launcher.
359 exe, err := os.Executable()
360 if err != nil {
361 return fmt.Errorf("migrate: resolve migrator executable: %w", err)
362 }
363 info, err := os.Lstat(exe)
364 if err != nil || !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
365 return fmt.Errorf("migrate: migrator executable is not a regular file")
366 }
367 body, err := os.ReadFile(exe)
368 if err != nil {
369 return fmt.Errorf("migrate: read launcher source: %w", err)
370 }
371 if err := fileutil.AtomicWriteFile(path, body, 0o755); err != nil {
372 return fmt.Errorf("migrate: install thin launcher: %w", err)
373 }
374 return nil
375 }
376
377 func finalizeLegacyPendingUpdate(installRoot, activeVersion string) error {
378 tx, err := repair.ReadPendingUpdate()
379 if os.IsNotExist(err) {
380 return nil
381 }
382 if err != nil {
383 return fmt.Errorf("migrate: read legacy pending update: %w", err)
384 }
385 if tx.TargetKind != "file" {
386 return fmt.Errorf("migrate: legacy pending update target kind %q is not supported", tx.TargetKind)
387 }
388 if strings.TrimSpace(tx.ToVersion) != strings.TrimSpace(activeVersion) {
389 return fmt.Errorf("migrate: pending update targets %s, not %s", tx.ToVersion, activeVersion)
390 }
391 for _, target := range append([]repair.UpdateTransactionFile{{TargetPath: tx.TargetPath}}, tx.Files...) {
392 if !pathWithinInstallRoot(installRoot, target.TargetPath) {
393 return fmt.Errorf("migrate: pending update target escapes install root")
394 }
395 }
396 id := repair.UpdateTransactionID(tx)
397 if err := repair.MarkUpdateHealthyExact(activeVersion, tx.CreatedAt, id); err != nil {
398 return fmt.Errorf("migrate: commit legacy pending update: %w", err)
399 }
400 if current, err := repair.ReadPendingUpdate(); err == nil {
401 if repair.UpdateTransactionID(current) == id {
402 return fmt.Errorf("migrate: legacy pending update remained after commit")
403 }
404 return fmt.Errorf("migrate: pending update changed during commit")
405 } else if !os.IsNotExist(err) {
406 return fmt.Errorf("migrate: verify legacy pending update commit: %w", err)
407 }
408 return nil
409 }
410
411 func pathWithinInstallRoot(installRoot, target string) bool {
412 root := filepath.Clean(strings.TrimSpace(installRoot))
413 target = filepath.Clean(strings.TrimSpace(target))
414 if root == "" || target == "" || !filepath.IsAbs(root) || !filepath.IsAbs(target) {
415 return false
416 }
417 rel, err := filepath.Rel(root, target)
418 if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) || filepath.IsAbs(rel) {
419 return false
420 }
421 return true
422 }
423
424 func cleanupLegacyFlatFiles(installRoot string) error {
425 // Remove flat desktop/CLI/helper/guard sidecars after the version tree is
426 // active. Never delete the thin launcher or current.json.
427 names := []string{
428 installlayout.DesktopBinaryName(),
429 installlayout.CLIBinaryName(),
430 installlayout.UpdateHelperBinaryName(),
431 }
432 if runtime.GOOS == "windows" {
433 names = append(names, "reasonix-guard.exe")
434 } else {
435 names = append(names, "reasonix-guard")
436 }
437 for _, name := range names {
438 path := filepath.Join(installRoot, name)
439 info, err := os.Lstat(path)
440 if err != nil || !info.Mode().IsRegular() {
441 continue
442 }
443 _ = os.Remove(path)
444 }
445 return nil
446 }
447
448 func archiveInstallRootLegacyMarkers(installRoot string) error {
449 // Very old portable builds wrote markers beside the binary. Current repair
450 // state under Reasonix home is finalized through repair transaction APIs.
451 markers := []string{
452 "pending-update.json",
453 "startup-state.json",
454 }
455 ts := time.Now().UTC().Format("20060102T150405Z")
456 destRoot := filepath.Join(installRoot, "repair", "legacy-v1", ts)
457 moved := false
458 for _, name := range markers {
459 src := filepath.Join(installRoot, name)
460 if _, err := os.Lstat(src); err != nil {
461 continue
462 }
463 if err := os.MkdirAll(destRoot, 0o755); err != nil {
464 return err
465 }
466 dest := filepath.Join(destRoot, name)
467 if err := os.Rename(src, dest); err != nil {
468 // Cross-device: copy+remove best effort.
469 data, readErr := os.ReadFile(src)
470 if readErr != nil {
471 continue
472 }
473 if writeErr := fileutil.AtomicWriteFile(dest, data, 0o600); writeErr == nil {
474 _ = os.Remove(src)
475 }
476 }
477 moved = true
478 }
479 if moved {
480 meta, _ := json.MarshalIndent(map[string]any{
481 "migratedAt": time.Now().UTC().Format(time.RFC3339),
482 "from": "flat-v1",
483 "to": installlayout.InstallLayoutVersionedV1,
484 }, "", " ")
485 _ = fileutil.AtomicWriteFile(filepath.Join(destRoot, "migration.json"), append(meta, '\n'), 0o644)
486 }
487 return nil
488 }
489
490 func startLauncher(installRoot string) error {
491 launcher := "reasonix-launcher"
492 if runtime.GOOS == "windows" {
493 launcher += ".exe"
494 }
495 path := filepath.Join(installRoot, launcher)
496 if _, err := os.Lstat(path); err != nil {
497 if runtime.GOOS == "windows" {
498 path = filepath.Join(installRoot, "Reasonix.exe")
499 }
500 }
501 if _, err := os.Lstat(path); err != nil {
502 // Migration succeeded; user can start manually.
503 return nil
504 }
505 cmd := exec.Command(path)
506 cmd.Dir = installRoot
507 cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
508 if runtime.GOOS == "windows" {
509 return cmd.Start()
510 }
511 return cmd.Start()
512 }
513
514 func selfDelete() {
515 exe, err := os.Executable()
516 if err != nil {
517 return
518 }
519 // Do not delete if we are not named like a compatibility guard payload.
520 base := strings.ToLower(filepath.Base(exe))
521 if base != "reasonix-guard" && base != "reasonix-guard.exe" {
522 return
523 }
524 _ = os.Remove(exe)
525 }
526
526 lines GO